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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ pip install -e .

```bash
komi-learn doctor # check the install and what to fix
komi-learn update # upgrade to the latest version (--check to only look)
komi-learn status # config + how much it has learned
komi-learn config # change any setting (menu, or `config set <key> <val>`)
komi-learn sync # pull the latest community learnings
Expand Down
22 changes: 21 additions & 1 deletion komi/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
"""komi-learn — a continuous, zero-friction learning layer for AI agents."""

__version__ = "0.1.0"
# THE single source of truth for the version. pyproject.toml reads this literal at
# build time via setuptools dynamic version ([tool.setuptools.dynamic] version =
# {attr = "komi.__version__"}), so there is exactly one place to bump on a release
# and the packaged metadata can never drift from this value by construction.
__version__ = "0.3.0"

# When installed, prefer the distribution metadata — it's the ground truth of what
# pip actually has on disk, which is what `komi-learn update` compares against
# PyPI. For a bare source tree (no installed dist) the literal above stands in.
# Because of the build-time attr binding the two agree, so this only matters for
# odd editable-install states — and it can only correct toward reality, never
# introduce a second hand-maintained number.
try: # importlib.metadata is stdlib on py3.8+
from importlib.metadata import PackageNotFoundError, version as _dist_version

try:
__version__ = _dist_version("komi-learn")
except PackageNotFoundError:
pass # not installed (source tree) — keep the literal
except Exception: # pragma: no cover - metadata API should always be present
pass
74 changes: 74 additions & 0 deletions komi/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,73 @@ def cmd_login(args) -> int:
return rc


def cmd_update(args) -> int:
"""Self-update: check PyPI for a newer komi-learn and upgrade in place.

Upgrades *this* interpreter's install (the one the hooks import), via pip or
pipx depending on how komi-learn was installed. Use --check to only report
whether an update is available. After upgrading, re-run `komi-learn install`
is NOT required for code — but if a release adds new hook events, run it to
refresh your settings."""
import komi
from komi import updater
from komi import cli_prompt as PR

current = getattr(komi, "__version__", "?")
_p(f"{PRODUCT}: installed {current}. Checking PyPI…")
latest = updater.check_latest()
if latest is None:
_p(f"{PRODUCT}: couldn't reach PyPI (offline?). Try again later, or upgrade manually:")
_p(f" {updater.plan_upgrade().display()}")
return 1
if not updater.is_newer(latest, current):
_p(f"{PRODUCT}: you're on the latest version ({current}). Nothing to do.")
return 0

_p(f"{PRODUCT}: a newer version is available — {current} → {latest}.")
plan = updater.plan_upgrade()

if getattr(args, "check", False):
_p(f" to upgrade: {plan.display()}")
return 0

if not plan.runnable:
# Couldn't identify a safe package manager — never guess, just instruct.
_p(f" couldn't auto-detect how {PRODUCT} was installed. Upgrade with:")
_p(f" {plan.display()}")
return 1

if not getattr(args, "yes", False):
if not PR.ask_yes_no(f" Upgrade now via {plan.manager}?", default=True,
summary=f"Runs: {plan.display()}"):
_p(f" skipped. Upgrade anytime with: {plan.display()}")
return 0

_p(f"\n upgrading via {plan.manager}…\n")
ok, detail = updater.run_upgrade(plan)
if not ok:
_p(f"\n{PRODUCT}: upgrade failed ({detail}). You can run it manually:")
_p(f" {plan.display()}")
return 1

# importlib.metadata is cached in this process; read the truth from a fresh one.
# Only claim a confirmed version when we actually read one — a non-zero pip
# exit isn't proof komi-learn reached `latest` (pip can no-op or install a
# pinned older version), so never substitute `latest` and call it confirmed.
new = updater.installed_version_via_subprocess()
if new is None:
_p(f"\n{PRODUCT}: upgrade command finished, but I couldn't confirm the "
"installed version.")
_p(" Check it with: komi-learn update --check")
else:
_p(f"\n{PRODUCT}: upgraded {current} → {new}.")
if updater.is_newer(latest, new):
_p(f" note: PyPI shows {latest} but the install reports {new} — "
"you may be in a different environment than expected.")
_p(" If this release added hook events, refresh them with: komi-learn install")
return 0


def cmd_queue(args) -> int:
"""Inspect + act on the global-contribution review queue (the human gate).

Expand Down Expand Up @@ -471,6 +538,13 @@ def build_parser() -> argparse.ArgumentParser:
pl = sub.add_parser("login", help="log in for free OAuth distillation (claude CLI)")
pl.set_defaults(func=cmd_login)

pup = sub.add_parser("update", help="check PyPI and upgrade komi-learn to the latest version")
pup.add_argument("--check", action="store_true",
help="only report whether an update is available; don't upgrade")
pup.add_argument("--yes", "-y", action="store_true",
help="upgrade without the confirmation prompt")
pup.set_defaults(func=cmd_update)

pc = sub.add_parser("curate", help="consolidate the learning library now (normally ~weekly)")
pc.add_argument("--dry-run", action="store_true", help="preview changes without applying")
pc.add_argument("--no-llm", action="store_true", help="prune only; don't merge clusters")
Expand Down
271 changes: 271 additions & 0 deletions komi/updater.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
"""komi-learn — self-update: check PyPI for a newer release and upgrade in place.

`komi-learn update` should Just Work regardless of how the user installed us. The
hard part isn't the PyPI check — it's upgrading *the right environment*. A blind
`pip install -U` can hit the wrong interpreter, fight a pipx-managed venv, or need
permissions it doesn't have. So we detect the install method first and run the
command that matches it (pip-into-this-interpreter, or `pipx upgrade`). If we
genuinely can't tell, we print the command instead of guessing and breaking the
user's environment.

Everything here is best-effort and network-failure-safe: a flaky PyPI lookup never
raises out of `check_latest`, it just returns ``None``.
"""

from __future__ import annotations

import json
import os
import re
import shutil
import subprocess
import sys
import urllib.request
from dataclasses import dataclass
from typing import Optional

DIST_NAME = "komi-learn"
PYPI_JSON_URL = f"https://pypi.org/pypi/{DIST_NAME}/json"
_PYPI_TIMEOUT = 8 # seconds — a stuck lookup must not hang the CLI
_MAX_PYPI_BYTES = 5 * 1024 * 1024 # cap the response read (one project's JSON << this)


class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""A redirect handler that refuses every redirect (returns None)."""

def redirect_request(self, *args, **kwargs): # noqa: D401
return None


# Module-level opener so we don't rebuild it per call. Refuses 3xx redirects.
_NO_REDIRECT_OPENER = urllib.request.build_opener(_NoRedirect)


# ── version comparison ───────────────────────────────────────────────────────
#
# A self-contained PEP 440-lite comparator. We deliberately do NOT depend on
# `packaging`: the engine's ethos is zero required deps, and "is 0.4.0 newer than
# 0.3.0" doesn't justify a runtime dependency that may or may not be present
# (depending on whether the lib happened to compute the answer differently per
# environment). This handles the version shapes komi-learn actually ships —
# release tuples, pre-releases (a/b/rc), post/dev, and a leading epoch — with one
# code path, so the answer never varies by environment.

# pre-release phase ranks: anything pre sorts BEFORE the plain release; post AFTER.
_PRE_RANK = {"a": 0, "alpha": 0, "b": 1, "beta": 1, "rc": 2, "c": 2, "pre": 2,
"preview": 2}
_FINAL = 3 # a plain release (no pre/post/dev) sits between pre and post
_POST = 4
# .devN sorts before everything at the same release (earliest), so it gets a phase
# below the lowest pre-rank.
_DEV = -1

_RELEASE_RE = re.compile(r"^(?:(\d+)!)?(\d+(?:\.\d+)*)(.*)$")
_PRE_RE = re.compile(r"[._-]?(a|b|c|rc|alpha|beta|pre|preview)[._-]?(\d*)")
_POST_RE = re.compile(r"[._-]?(?:post|rev|r)[._-]?(\d*)|-(\d+)")
_DEV_RE = re.compile(r"[._-]?dev[._-]?(\d*)")


def _norm_release(rel: str) -> tuple:
"""Numeric release tuple with trailing zeros stripped so 1.0 == 1.0.0."""
parts = [int(p) for p in rel.split(".")]
while len(parts) > 1 and parts[-1] == 0:
parts.pop()
return tuple(parts)


def _version_key(v: str):
"""Map a version string to a tuple that sorts per PEP 440 (for the shapes we
ship). Unparseable input sorts lowest so it never spuriously beats a real
release. The key shape is:
(epoch, release_tuple, phase, phase_num, dev_num)
where ``phase`` orders dev < pre(a<b<rc) < final < post.
"""
s = (v or "").strip().lower()
if s[:1] == "v": # tolerate a leading v/V tag
s = s[1:]
m = _RELEASE_RE.match(s)
if not m:
return (-1, (), _DEV, -1, -1) # lowest possible
epoch = int(m.group(1) or 0)
release = _norm_release(m.group(2))
suffix = m.group(3) or ""

dev = _DEV_RE.search(suffix)
pre = _PRE_RE.search(suffix)
post = _POST_RE.search(suffix)

# dev takes precedence as the earliest phase; then pre; then post; else final.
if dev:
phase, phase_num = _DEV, int(dev.group(1) or 0)
elif pre:
phase, phase_num = _PRE_RANK.get(pre.group(1), 2), int(pre.group(2) or 0)
elif post:
phase, phase_num = _POST, int(post.group(1) or post.group(2) or 0)
else:
phase, phase_num = _FINAL, 0
dev_num = int(dev.group(1) or 0) if dev else 0
return (epoch, release, phase, phase_num, dev_num)


# kept as a public helper (tests + back-compat); now returns the release tuple.
def _parse_version(v: str) -> tuple:
"""The numeric release tuple (trailing zeros stripped), e.g. "0.4.0rc1"->(0,4).
For full ordering (incl. pre/post/epoch) use :func:`is_newer`."""
return _version_key(v)[1] or (0,)


def is_newer(latest: str, current: str) -> bool:
"""True if ``latest`` is a strictly newer version than ``current`` (PEP 440-lite)."""
return _version_key(latest) > _version_key(current)


# ── PyPI lookup ──────────────────────────────────────────────────────────────

def check_latest(*, timeout: int = _PYPI_TIMEOUT) -> Optional[str]:
"""Return the latest version string on PyPI, or ``None`` if it can't be
determined (offline, PyPI down, malformed payload). Never raises."""
try:
req = urllib.request.Request(
PYPI_JSON_URL, headers={"Accept": "application/json",
"User-Agent": f"{DIST_NAME}-updater"})
# Refuse redirects: the hardcoded https literal only guarantees the FIRST
# hop. urllib's default handler would follow a 3xx to any host/scheme,
# including http — a MITM could downgrade the lookup and feed us a fake
# "newer" version to coerce an upgrade. PyPI's JSON API doesn't redirect
# cross-scheme here, so refusing is safe.
with _NO_REDIRECT_OPENER.open(req, timeout=timeout) as resp: # nosec B310 - https, no-redirect
if not (resp.geturl() or "").lower().startswith("https://"):
return None
# Bound the read: a hostile endpoint must not be able to stream a huge
# body and OOM the CLI. One project's JSON is far under this cap.
raw = resp.read(_MAX_PYPI_BYTES + 1)
if len(raw) > _MAX_PYPI_BYTES:
return None
data = json.loads(raw.decode("utf-8"))
ver = (data.get("info") or {}).get("version")
return ver or None
except Exception:
return None


# ── install-method detection ─────────────────────────────────────────────────

def _is_pipx() -> bool:
"""Are we running from a pipx-managed venv? pipx installs each app into
``.../pipx/venvs/<app>/``.

We key off the *interpreter prefix* — the one signal an attacker can't set
just by exporting an env var. A bare ``PIPX_HOME`` is NOT sufficient on its
own: that would let a hostile environment flip a plain pip install into the
pipx branch (and then a planted ``pipx`` on PATH would run). The env var only
*corroborates* a prefix that already lives under it.
"""
prefix = (sys.prefix or "").replace("\\", "/")
low = prefix.lower()
if "/pipx/venvs/" in low or "/pipx/shared" in low:
return True
# If PIPX_HOME is set AND our interpreter actually lives under it, trust it.
pipx_home = (os.environ.get("PIPX_HOME") or "").replace("\\", "/")
if pipx_home and prefix.startswith(pipx_home):
return True
return False


def _pip_available() -> bool:
try:
import pip # noqa: F401
return True
except Exception:
return False


@dataclass
class UpgradePlan:
"""How we intend to upgrade. ``cmd`` is the argv to run; ``manager`` is for
display; ``runnable`` is False when we couldn't determine a safe command (then
``cmd`` is the human-facing suggestion to print, not to execute)."""
manager: str
cmd: list
runnable: bool

def display(self) -> str:
return " ".join(self.cmd)


def plan_upgrade() -> UpgradePlan:
"""Decide the upgrade command for *this* environment."""
if _is_pipx():
# pipx manages its own venv; pip -U inside it is the wrong tool. Resolve
# pipx to an ABSOLUTE path (never an unqualified "pipx" off PATH — that
# would let a planted binary earlier in PATH run during `update`). If we
# can't find it, refuse and print the command rather than guess.
pipx_path = shutil.which("pipx")
if not pipx_path:
return UpgradePlan("pipx", ["pipx", "upgrade", DIST_NAME], runnable=False)
return UpgradePlan("pipx", [pipx_path, "upgrade", DIST_NAME], runnable=True)
if _pip_available():
# Upgrade into the very interpreter that's running komi-learn — this is the
# one whose `import komi` the hooks use. Mirrors model_install.py.
return UpgradePlan(
"pip",
[sys.executable, "-m", "pip", "install", "--upgrade", DIST_NAME],
runnable=True,
)
# Couldn't find a package manager we trust to drive — hand the user a command
# rather than risk corrupting a standalone/frozen install.
return UpgradePlan("pip", ["pip", "install", "--upgrade", DIST_NAME], runnable=False)


# ── upgrade execution + re-verify ────────────────────────────────────────────

def installed_version_via_subprocess() -> Optional[str]:
"""Read komi-learn's version in a *fresh* interpreter.

importlib.metadata caches distribution info for the life of a process, so after
an in-process pip upgrade the running interpreter still reports the OLD version.
Shelling out to a clean python gets the truth post-upgrade.
"""
# Pass the dist name as argv (sys.argv[1]) rather than interpolating it into
# the code string — keeps the snippet free of string-building even though
# DIST_NAME is a trusted literal.
code = (
"import sys\n"
"try:\n"
" from importlib.metadata import version\n"
" sys.stdout.write(version(sys.argv[1]))\n"
"except Exception:\n"
" pass\n"
)
try:
r = subprocess.run([sys.executable, "-c", code, DIST_NAME],
capture_output=True, text=True, timeout=30)
out = (r.stdout or "").strip()
return out or None
except Exception:
return None


def run_upgrade(plan: UpgradePlan, *, timeout: int = 1200) -> tuple[bool, str]:
"""Execute the upgrade command. Returns (ok, detail). Output streams to the
user's terminal so they see pip's progress (and any resolver errors)."""
if not plan.runnable:
return False, "no runnable upgrade command for this environment"
try:
r = subprocess.run(plan.cmd, timeout=timeout)
except FileNotFoundError:
return False, f"`{plan.cmd[0]}` not found on PATH"
except subprocess.TimeoutExpired:
return False, "upgrade timed out"
except Exception as e:
return False, f"upgrade failed to launch: {e}"
if r.returncode != 0:
return False, f"upgrade exited {r.returncode}"
return True, "upgraded"


__all__ = [
"DIST_NAME", "PYPI_JSON_URL",
"check_latest", "is_newer", "plan_upgrade", "UpgradePlan",
"run_upgrade", "installed_version_via_subprocess",
]
Loading
Loading