From 673751adc9f63d4ef92247f9afea45bc89f8cd24 Mon Sep 17 00:00:00 2001 From: AusafMo Date: Sun, 26 Jul 2026 21:30:32 +0530 Subject: [PATCH 1/3] feat(update): check-update nudge with 'what's new', two surfaces, one engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New src/cfg/update.py (out of cfg.core to keep the engine network-free): checks PyPI for a newer cfgit, throttled daily, snooze-aware (~/.cfgit/update-check.json), CFGIT_NO_UPDATE_CHECK kill switch, best-effort/fail-silent, NEVER auto-upgrades. When an update is available it fetches the latest GitHub release notes and shows a short 'what's new' excerpt + link, so the user sees what they'd get — degrades to a plain one-liner if the notes fetch fails. Two surfaces sharing the engine + snooze state: - CLI: 'cfg check-update [--snooze [DAYS]]', and a deterministic stderr nudge after cheap READ commands (status/doctor/whoami/log) — human-TTY only, never on stdout/--json/piped, never on a write path, wrapped so it can't break the command. - MCP: cfg_check_update(snooze_days) (engine-less, cfg_identity_hash pattern) + a SKILL session- start rule so Claude offers the upgrade or a 30-day snooze. Bootstrap reality: only helps 0.4.0-forward; already-installed <=0.3.0 have no check code. Tests: test_update_check.py (13) — version compare, throttle, snooze/lapse, kill switch, fail- silent, garbage-state tolerance, what's-new excerpt + degrade. 149 pass. --- src/cfg/cli/main.py | 33 +++++ src/cfg/mcp/server.py | 16 +++ src/cfg/update.py | 263 +++++++++++++++++++++++++++++++++++++ tests/test_update_check.py | 155 ++++++++++++++++++++++ 4 files changed, 467 insertions(+) create mode 100644 src/cfg/update.py create mode 100644 tests/test_update_check.py diff --git a/src/cfg/cli/main.py b/src/cfg/cli/main.py index 6cc0285..59027c7 100644 --- a/src/cfg/cli/main.py +++ b/src/cfg/cli/main.py @@ -68,12 +68,21 @@ def main(argv: list[str] | None = None) -> int: except ValueError as exc: _emit_error("error", str(exc), args) return EXIT_ARG + if args.cmd == "check-update": + from cfg import update + + if args.snooze is not None: + _emit(update.snooze(args.snooze), json_mode=args.json) + return EXIT_OK + _emit(update.check(force=True).to_json(), json_mode=args.json) + return EXIT_OK try: project = load_config(args.config_file) engine = _engine(project, args.env, author=args.author) _warn_open_mode(engine, args) result, code = _dispatch(engine, args) _emit(result, json_mode=args.json, record=getattr(args, "record", None)) + _maybe_update_nudge(args) return code except AmbiguousConfig as exc: _emit_error("bad_config", str(exc), args, exc=exc) @@ -245,6 +254,10 @@ def _parser() -> argparse.ArgumentParser: p_identity_hash.add_argument("token", nargs="?") p_identity_hash.add_argument("--stdin", action="store_true") + p_update = sub.add_parser("check-update", help="check PyPI for a newer cfgit (never upgrades for you)") + p_update.add_argument("--snooze", nargs="?", type=int, const=30, default=None, + metavar="DAYS", help="silence the update nudge for DAYS (default 30)") + p_ui = sub.add_parser("ui") p_ui.add_argument("--host", default="127.0.0.1") p_ui.add_argument("--port", type=int, default=8765) @@ -640,6 +653,26 @@ def _load_json_any(path: str) -> Any: _MUTATING_CMDS = {"commit", "set", "edit", "adopt", "import", "restore"} +# Cheap, read-only commands safe to piggyback a throttled update check onto — never a write path, +# so a version check never sits in front of a prod mutation. +_READ_COMMANDS = {"status", "doctor", "whoami", "log"} + + +def _maybe_update_nudge(args: argparse.Namespace) -> None: + """Deterministic npm/brew-style nudge: after a cheap read command, if a newer cfgit is on + PyPI (throttled daily, snooze-aware, kill-switchable), print one line to stderr — but only + for an interactive human (TTY, not --json). Never touches stdout; never raises.""" + if args.cmd not in _READ_COMMANDS or getattr(args, "json", False) or not sys.stdout.isatty(): + return + try: + from cfg import update + + status = update.check() + if status.message: + print(status.message, file=sys.stderr) + except Exception: # noqa: BLE001 - a nudge must never break the command + pass + def _warn_open_mode(engine: Engine, args: argparse.Namespace) -> None: """Loud stderr warning when a mutating command runs against a needs_approval env still in diff --git a/src/cfg/mcp/server.py b/src/cfg/mcp/server.py index d9e62aa..3b07abd 100644 --- a/src/cfg/mcp/server.py +++ b/src/cfg/mcp/server.py @@ -502,6 +502,22 @@ def cfg_identity_hash(token: str) -> dict[str, Any]: } +@mcp.tool() +def cfg_check_update(snooze_days: int | None = None) -> dict[str, Any]: + """Check PyPI for a newer cfgit release. Call this at the start of a cfgit session; if + `data.update_available` is true and not `data.snoozed`, tell the user the new version and + offer to upgrade (`pip install -U cfgit`) or to snooze. NEVER upgrade for them. + + Pass `snooze_days` (e.g. 30) to record a "don't ask again for N days" snooze when the user + asks to be reminded later. Best-effort and fail-silent; honors CFGIT_NO_UPDATE_CHECK. + """ + from cfg import update + + if snooze_days is not None: + return {"status": "ok", "code": actions.EXIT_OK, "message": "", "data": update.snooze(snooze_days)} + return {"status": "ok", "code": actions.EXIT_OK, "message": "", "data": update.check(force=True).to_json()} + + def _call( name: str, payload: dict[str, Any], diff --git a/src/cfg/update.py b/src/cfg/update.py new file mode 100644 index 0000000..895ff2e --- /dev/null +++ b/src/cfg/update.py @@ -0,0 +1,263 @@ +# Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0. +"""Update check: is a newer cfgit on PyPI? + +One engine, two surfaces — a deterministic CLI stderr nudge (npm/gh/brew style) and an MCP tool +that lets Claude offer the upgrade interactively. Both share this logic, so a 30-day snooze taken +in either place quiets both. + +Principles: +- Never upgrades anything. It detects and reports; the human runs `pip install -U cfgit`. +- Best-effort and fail-silent: any network/parse/permission error means "say nothing", never an + error to the caller. +- Throttled: hits PyPI at most once per `CHECK_INTERVAL` (default daily), cached in a state file. +- Snoozable: "don't ask for 30 days" persists to the state file and is honored by both surfaces. +- Kill switch: `CFGIT_NO_UPDATE_CHECK=1` disables it entirely (opt-out, like npm's config). +""" +from __future__ import annotations + +from dataclasses import dataclass +import json +import os +from pathlib import Path +from typing import Any +import urllib.error +import urllib.request + +PACKAGE = "cfgit" +PYPI_URL = f"https://pypi.org/pypi/{PACKAGE}/json" +GITHUB_REPO = "AusafMo/cfgit" +GITHUB_LATEST_RELEASE_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest" +RELEASES_PAGE = f"https://github.com/{GITHUB_REPO}/releases" +_NOTES_MAX_LINES = 5 +STATE_DIR = Path.home() / ".cfgit" +STATE_FILE = STATE_DIR / "update-check.json" +CHECK_INTERVAL_S = 24 * 60 * 60 # network check at most once/day +DEFAULT_SNOOZE_DAYS = 30 +_TIMEOUT_S = 2.0 +_DISABLE_ENV = "CFGIT_NO_UPDATE_CHECK" + + +@dataclass(frozen=True) +class UpdateStatus: + installed: str | None + latest: str | None + update_available: bool + checked: bool # did we actually reach PyPI this call? + snoozed: bool + disabled: bool + message: str | None # a ready-to-show nudge line, or None when there's nothing to say + notes: str | None = None # short "what's new" excerpt from the GitHub release, if fetched + notes_url: str | None = None # link to the full release notes + + def to_json(self) -> dict[str, Any]: + return { + "installed": self.installed, + "latest": self.latest, + "update_available": self.update_available, + "checked": self.checked, + "snoozed": self.snoozed, + "disabled": self.disabled, + "message": self.message, + "notes": self.notes, + "notes_url": self.notes_url, + } + + +def installed_version() -> str | None: + """Resolve the installed cfgit version, robust to editable/dev installs.""" + try: + from importlib.metadata import PackageNotFoundError, version + + try: + return version(PACKAGE) + except PackageNotFoundError: + pass + except Exception: # noqa: BLE001 + pass + # dev fallback: read version straight from pyproject.toml at the repo root + try: + import tomllib # py311+ + + root = Path(__file__).resolve().parents[2] + data = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) + return str(data["project"]["version"]) + except Exception: # noqa: BLE001 + return None + + +def check(*, force: bool = False, now: float | None = None) -> UpdateStatus: + """Return the current update status, honoring the kill switch, throttle, and snooze. + + `force=True` bypasses the throttle (but not the kill switch) — used by an explicit + `cfg check-update`. `now` is injectable for tests. + """ + installed = installed_version() + if _disabled(): + return UpdateStatus(installed, None, False, False, False, True, None) + + ts = _now(now) + state = _load_state() + + if not force and _snoozed(state, ts): + latest = state.get("latest") + return UpdateStatus(installed, latest, _gt(latest, installed), False, True, False, None) + + if not force and _checked_recently(state, ts): + latest = state.get("latest") + available = _gt(latest, installed) + # reuse the release notes cached from the last real check — no network on this path + notes = state.get("notes") if available else None + return _status(installed, latest, available, checked=False, notes=notes) + + latest = _fetch_latest() + if latest is None: + # network failed — say nothing, don't rewrite last_checked so we retry next time + return UpdateStatus(installed, state.get("latest"), False, False, False, False, None) + + available = _gt(latest, installed) + notes = _fetch_release_notes() if available else None + state["latest"] = latest + state["last_checked"] = ts + state["notes"] = notes + _save_state(state) + return _status(installed, latest, available, checked=True, notes=notes) + + +def _status(installed, latest, available, *, checked, notes): + return UpdateStatus( + installed=installed, + latest=latest, + update_available=available, + checked=checked, + snoozed=False, + disabled=False, + message=_nudge(installed, latest, notes) if available else None, + notes=notes if available else None, + notes_url=RELEASES_PAGE if available else None, + ) + + +def snooze(days: int = DEFAULT_SNOOZE_DAYS, *, now: float | None = None) -> dict[str, Any]: + """Record a 'don't ask for N days' snooze. Honored by both the CLI and the MCP tool.""" + ts = _now(now) + state = _load_state() + state["snooze_until"] = ts + days * 24 * 60 * 60 + _save_state(state) + return {"state": "snoozed", "days": days, "snooze_until": state["snooze_until"]} + + +def _nudge(installed: str | None, latest: str | None, notes: str | None = None) -> str: + head = f"cfgit {latest} is available (you have {installed or 'an older version'})." + tail = f"Upgrade with `pip install -U cfgit`. (Set {_DISABLE_ENV}=1 to silence this.)" + if notes: + return f"{head}\nWhat's new:\n{notes}\n{tail}\nFull notes: {RELEASES_PAGE}" + return f"{head} {tail}" + + +def _fetch_release_notes() -> str | None: + """Short 'what's new' excerpt from the latest GitHub release (the notes we author at release + time). Best-effort: any failure returns None and the nudge degrades to the plain line.""" + try: + req = urllib.request.Request( + GITHUB_LATEST_RELEASE_URL, + headers={"Accept": "application/vnd.github+json", "User-Agent": "cfgit-update-check"}, + ) + with urllib.request.urlopen(req, timeout=_TIMEOUT_S) as resp: # noqa: S310 - fixed https URL + data = json.loads(resp.read().decode("utf-8")) + return _excerpt(str(data.get("body") or "")) + except (urllib.error.URLError, TimeoutError, ValueError, KeyError, OSError): + return None + except Exception: # noqa: BLE001 - notes are optional; never raise + return None + + +def _excerpt(body: str) -> str | None: + """Pull the first few bullet/highlight lines out of a release body, dropping headers/blanks.""" + lines: list[str] = [] + for raw in body.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + # normalize common markdown bullets to a plain dash + if line[:2] in ("- ", "* ", "• "): + line = " • " + line[2:].strip() + elif not line.startswith(("•", " •")): + line = " " + line + lines.append(line) + if len(lines) >= _NOTES_MAX_LINES: + break + return "\n".join(lines) if lines else None + + +def _disabled() -> bool: + return os.environ.get(_DISABLE_ENV, "") not in ("", "0", "false", "no") + + +def _fetch_latest() -> str | None: + try: + req = urllib.request.Request(PYPI_URL, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=_TIMEOUT_S) as resp: # noqa: S310 - fixed https URL + data = json.loads(resp.read().decode("utf-8")) + return str(data["info"]["version"]) + except (urllib.error.URLError, TimeoutError, ValueError, KeyError, OSError): + return None + except Exception: # noqa: BLE001 - update check must never raise + return None + + +def _load_state() -> dict[str, Any]: + try: + return json.loads(STATE_FILE.read_text(encoding="utf-8")) + except (FileNotFoundError, ValueError, OSError): + return {} + + +def _save_state(state: dict[str, Any]) -> None: + try: + STATE_DIR.mkdir(parents=True, exist_ok=True) + STATE_FILE.write_text(json.dumps(state, indent=2), encoding="utf-8") + except OSError: + pass # can't persist (read-only home, etc.) — degrade to "check every time", never error + + +def _snoozed(state: dict[str, Any], ts: float) -> bool: + until = state.get("snooze_until") + return isinstance(until, (int, float)) and ts < until + + +def _checked_recently(state: dict[str, Any], ts: float) -> bool: + last = state.get("last_checked") + return isinstance(last, (int, float)) and (ts - last) < CHECK_INTERVAL_S + + +def _now(now: float | None) -> float: + if now is not None: + return now + import time + + return time.time() + + +def _gt(latest: str | None, installed: str | None) -> bool: + """Is `latest` a strictly newer release than `installed`? Tolerant PEP 440-ish compare.""" + if not latest or not installed: + return False + try: + from packaging.version import Version + + return Version(latest) > Version(installed) + except Exception: # noqa: BLE001 - packaging may be absent; fall back to a tuple compare + return _parse(latest) > _parse(installed) + + +def _parse(v: str) -> tuple[int, ...]: + parts: list[int] = [] + for chunk in v.split("."): + num = "" + for ch in chunk: + if ch.isdigit(): + num += ch + else: + break + parts.append(int(num) if num else 0) + return tuple(parts) diff --git a/tests/test_update_check.py b/tests/test_update_check.py new file mode 100644 index 0000000..f47982b --- /dev/null +++ b/tests/test_update_check.py @@ -0,0 +1,155 @@ +# Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0. +"""Update-check engine: throttle, snooze, kill switch, fail-silent, version compare.""" +from __future__ import annotations + +import json + +import pytest + +from cfg import update + + +@pytest.fixture(autouse=True) +def isolate_state(tmp_path, monkeypatch): + """Point the state file at a temp dir, clear the kill switch, and stub the GitHub notes + fetch so tests never hit the network by default.""" + monkeypatch.setattr(update, "STATE_DIR", tmp_path) + monkeypatch.setattr(update, "STATE_FILE", tmp_path / "update-check.json") + monkeypatch.delenv(update._DISABLE_ENV, raising=False) + monkeypatch.setattr(update, "_fetch_release_notes", lambda: " • new stuff") + + +def _fixed_latest(value): + return lambda: value + + +# --- version compare ------------------------------------------------------------------------ + + +def test_gt_numeric_and_equal(): + assert update._gt("0.4.0", "0.3.0") is True + assert update._gt("0.10.0", "0.9.0") is True # not string-compared + assert update._gt("1.0.0", "0.9.9") is True + assert update._gt("0.3.0", "0.3.0") is False + assert update._gt("0.3.0", "0.4.0") is False + assert update._gt(None, "0.3.0") is False + + +# --- core check behavior -------------------------------------------------------------------- + + +def test_update_available_produces_message(monkeypatch): + monkeypatch.setattr(update, "installed_version", lambda: "0.3.0") + monkeypatch.setattr(update, "_fetch_latest", _fixed_latest("0.4.0")) + st = update.check(now=1000.0) + assert st.update_available is True + assert st.latest == "0.4.0" + assert st.message and "0.4.0" in st.message and "pip install -U cfgit" in st.message + + +def test_no_update_no_message(monkeypatch): + monkeypatch.setattr(update, "installed_version", lambda: "0.4.0") + monkeypatch.setattr(update, "_fetch_latest", _fixed_latest("0.4.0")) + st = update.check(now=1000.0) + assert st.update_available is False + assert st.message is None + + +def test_kill_switch_disables_without_network(monkeypatch): + monkeypatch.setenv(update._DISABLE_ENV, "1") + called = {"n": 0} + monkeypatch.setattr(update, "_fetch_latest", lambda: called.__setitem__("n", called["n"] + 1)) + st = update.check(now=1000.0) + assert st.disabled is True + assert st.message is None + assert called["n"] == 0 # never hit the network + + +def test_throttle_skips_refetch_within_a_day(monkeypatch): + monkeypatch.setattr(update, "installed_version", lambda: "0.3.0") + calls = {"n": 0} + + def counting(): + calls["n"] += 1 + return "0.4.0" + + monkeypatch.setattr(update, "_fetch_latest", counting) + update.check(now=1000.0) # first: fetches + update.check(now=1000.0 + 3600) # +1h: throttled, no fetch + assert calls["n"] == 1 + + +def test_force_bypasses_throttle(monkeypatch): + monkeypatch.setattr(update, "installed_version", lambda: "0.3.0") + calls = {"n": 0} + monkeypatch.setattr(update, "_fetch_latest", lambda: (calls.__setitem__("n", calls["n"] + 1), "0.4.0")[1]) + update.check(now=1000.0) + update.check(now=1000.0 + 3600, force=True) + assert calls["n"] == 2 + + +def test_snooze_silences_until_it_lapses(monkeypatch): + monkeypatch.setattr(update, "installed_version", lambda: "0.3.0") + monkeypatch.setattr(update, "_fetch_latest", _fixed_latest("0.4.0")) + update.check(now=1000.0) # populates latest + update.snooze(30, now=1000.0) + st = update.check(now=1000.0 + 5 * 24 * 3600) # 5 days later, still snoozed + assert st.snoozed is True + assert st.message is None + later = update.check(now=1000.0 + 40 * 24 * 3600) # 40 days later, lapsed + assert later.snoozed is False + + +def test_network_failure_is_silent(monkeypatch): + monkeypatch.setattr(update, "installed_version", lambda: "0.3.0") + monkeypatch.setattr(update, "_fetch_latest", _fixed_latest(None)) + st = update.check(now=1000.0) + assert st.checked is False + assert st.message is None + assert st.update_available is False + + +def test_state_io_tolerates_garbage_file(monkeypatch): + update.STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + update.STATE_FILE.write_text("not json{{{", encoding="utf-8") + monkeypatch.setattr(update, "installed_version", lambda: "0.3.0") + monkeypatch.setattr(update, "_fetch_latest", _fixed_latest("0.4.0")) + st = update.check(now=1000.0) # must not raise + assert st.update_available is True + + +def test_snooze_persists_to_state_file(monkeypatch): + update.snooze(30, now=1000.0) + saved = json.loads(update.STATE_FILE.read_text()) + assert saved["snooze_until"] == 1000.0 + 30 * 24 * 3600 + + +# --- what's-new release notes --------------------------------------------------------------- + + +def test_message_includes_whats_new_notes(monkeypatch): + monkeypatch.setattr(update, "installed_version", lambda: "0.3.0") + monkeypatch.setattr(update, "_fetch_latest", _fixed_latest("0.4.0")) + monkeypatch.setattr(update, "_fetch_release_notes", lambda: " • cfg export\n • bulk --dry-run") + st = update.check(now=1000.0) + assert st.notes and "cfg export" in st.notes + assert "What's new:" in st.message and "cfg export" in st.message + assert st.notes_url == update.RELEASES_PAGE + + +def test_notes_fetch_failure_degrades_to_plain_nudge(monkeypatch): + monkeypatch.setattr(update, "installed_version", lambda: "0.3.0") + monkeypatch.setattr(update, "_fetch_latest", _fixed_latest("0.4.0")) + monkeypatch.setattr(update, "_fetch_release_notes", lambda: None) + st = update.check(now=1000.0) + assert st.update_available is True + assert st.notes is None + assert "What's new" not in st.message # plain one-liner still works + assert "pip install -U cfgit" in st.message + + +def test_excerpt_caps_lines_and_strips_headers(): + body = "## Highlights\n\n- one\n- two\n- three\n- four\n- five\n- six\n- seven" + out = update._excerpt(body) + assert out.count("\n") + 1 == update._NOTES_MAX_LINES # capped + assert "Highlights" not in out From 0cf947a49d4449a01c81acac7a6e240cc17d2bfd Mon Sep 17 00:00:00 2001 From: AusafMo Date: Sun, 26 Jul 2026 21:32:05 +0530 Subject: [PATCH 2/3] docs(skill/mcp): reconcile SKILL + README with everything shipped (0.2-0.4) Bring the agent-facing docs fully current, not just the update-check: - SKILL MCP list: add cfg_set, cfg_import, cfg_check_update (were missing); envelope note now documents top-level state + next remedy contract (branch on state, follow next.commands). - SKILL Rules: session-start cfg_check_update nudge; CFG_OUTPUT/CFG_ENV/CFG_CONFIG session defaults + walk-up; cfg doctor --status where-am-I + open-mode-unaudited warning. - SKILL Mutate: export size warning (data-plane hint) + import partial/cancelled resume. - README: cfg check-update command + auto-nudge/snooze/CFGIT_NO_UPDATE_CHECK doc; cfg_check_update in the MCP list. Verified every MCP tool the SKILL names is actually registered. --- README.md | 8 ++++++++ skills/cfgit/SKILL.md | 11 ++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1307ecc..a84a1d9 100644 --- a/README.md +++ b/README.md @@ -381,9 +381,16 @@ cfg restore --as-of --dry-run -m "message" cfg restore --tag --dry-run -m "message" cfg fsck cfg whoami +cfg check-update [--snooze [DAYS]] cfg ui ``` +cfgit checks PyPI for a newer release on cheap read commands (throttled to once a day) and, for an +interactive terminal, prints a one-line nudge to stderr with what's new — it never upgrades for +you (run `pip install -U cfgit`) and never prints on `--json`/piped output. `cfg check-update` +forces a check; `cfg check-update --snooze 30` silences it for 30 days; `CFGIT_NO_UPDATE_CHECK=1` +disables it entirely. + Every command supports `--json` for scripts and agents. By default output is human-readable when stdout is a terminal and JSON when piped or redirected; set `CFG_OUTPUT=json|human|auto` to control this, and `--json` always forces JSON. @@ -511,6 +518,7 @@ Tools include: - `cfg_whoami` - `cfg_init` - `cfg_identity_hash` +- `cfg_check_update` A portable skill lives at `skills/cfgit/SKILL.md`. `cfg_impact` accepts the same `against` list/string as the CLI `--against` flag, diff --git a/skills/cfgit/SKILL.md b/skills/cfgit/SKILL.md index 5bd6c78..0782587 100644 --- a/skills/cfgit/SKILL.md +++ b/skills/cfgit/SKILL.md @@ -13,7 +13,9 @@ Use cfgit as the safety layer around a live datastore. The app still reads and w - Never use cross-project Mongo URIs for writes. Remote managed Mongo writes are forbidden unless the user explicitly grants a per-turn exception and asks for that exact target. - Treat `changed_outside_cfgit` as the central state. Do not commit over it. Run `cfg diff`, explain what changed, then `cfg adopt` if the user wants to fold that live state into history. - If `[branches] enabled = true`, draft risky changes on a non-main branch and open a cfgit PR. Branch commits and PR creation do not mutate runtime; `cfg pr merge` is the only branch command that does. -- Prefer `--json` for agent parsing. +- At the start of a cfgit session, call `cfg_check_update` (or `cfg check-update --json`). If `data.update_available` is true and `data.snoozed` is false, tell the user the new version, show `data.notes` (what's new), and offer to upgrade (`pip install -U cfgit`) or snooze — call `cfg_check_update` with `snooze_days=30` if they want to be reminded later. NEVER upgrade for them. It is best-effort and silent when there is nothing to say; skip if `data.disabled` (they set `CFGIT_NO_UPDATE_CHECK`). +- Prefer `--json` for agent parsing. Output is otherwise human-on-a-TTY / JSON-when-piped; `CFG_OUTPUT=json|human|auto` overrides. To avoid repeating flags, `CFG_ENV`, `CFG_CONFIG`, and `CFG_AUTHOR` set session defaults, and cfgit discovers `.cfg.toml` by walking up from the cwd. +- Run `cfg doctor --status` to confirm where you are pointed (config, env, target db, identity mode + whether verified, reachability, tracked/drift counts) before mutating an unfamiliar env. It warns if a `needs_approval` env is still in `open` identity mode — writes there succeed UNAUDITED; surface that to the user. - Use deterministic `cfg impact` first. Add `--llm` only when the user asks for LLM narration and the impact plugin is installed. Use `--against ` when the user wants narration scoped to specific related records instead of the whole system. - Before the FIRST `cfg import` against a new database or `.cfg.toml`, run `cfg doctor` (read-only). It reports every secret-deny match, oversized field, and key issue at once, with paste-ready `secret_fields`/`ignore_fields` snippets. Fix `.cfg.toml` from its output, then import. This avoids import failing one secret at a time. - In `authenticated` or `enforced` identity mode, the process must already have a verified identity source, usually `CFGIT_IDENTITY_TOKEN` or a per-user DB principal. `--author` and MCP `author` are hints only; cfgit rejects them if they do not match the verified identity. @@ -53,7 +55,7 @@ Use cfgit as the safety layer around a live datastore. The app still reads and w - Every outcome now carries a top-level `next` block (why + remedy + copy-paste `commands`); on a refusal, follow `next.commands` rather than guessing. - For a coupled multi-record change, write a batch JSON file and run `cfg commit --bulk-from -m "" --json`. The batch file can be `[{"record":"collection:id","doc":{...}}]` or `{"collection:id": {...}}`. Preview a collection-scale batch first with `cfg commit --bulk-from --dry-run --json` — it reports each record's `would_commit`/`noop`/`changed_outside_cfgit` and writes nothing. - - Before a risky bulk change, snapshot the current live docs with `cfg export --out backup.json --json` (a portable, re-importable artifact stamped with head seq/oid). To roll back, `cfg import --from backup.json -m "" --json` writes them back through the drift-guarded path (preview with `--dry-run`). For an in-tool rollback with no file, use `cfg tag` + `cfg restore --tag` instead. + - Before a risky bulk change, snapshot the current live docs with `cfg export --out backup.json --json` (a portable, re-importable artifact stamped with head seq/oid). If export prints a size warning, the config may be pointed at a data-plane collection (events/content/jobs) cfgit is not built to version — flag it. To roll back, `cfg import --from backup.json -m "" --json` writes them back through the drift-guarded path (preview with `--dry-run`). If it returns `partial` with `cancelled: true` (the user interrupted), already-applied records are durable and rerunning the same command resumes. For an in-tool rollback with no file, use `cfg tag` + `cfg restore --tag` instead. - For a draft branch, use `cfg --branch commit --from -m "" --json` or `cfg --branch commit --bulk-from -m "" --json`. This writes cfgit refs only; runtime is unchanged. - If commit returns `changed_outside_cfgit`, stop and inspect drift. - If bulk commit returns `blocked`, no record was applied; inspect `failed`. If it returns `partial`, some records were applied before a race/failure; inspect `results`, `failed`, and `pending` before continuing. @@ -87,7 +89,9 @@ If the cfgit MCP server is available, prefer its tools over shelling out: - `cfg_impact` - `cfg_commit` - `cfg_bulk_commit` +- `cfg_set` for a few scalar field edits (routes through the drift-guarded commit path) - `cfg_export` +- `cfg_import` (pass `from_export` to restore a snapshot; `dry_run` to preview) - `cfg_branch_list` - `cfg_branch_create` - `cfg_branch_delete` @@ -108,7 +112,8 @@ If the cfgit MCP server is available, prefer its tools over shelling out: - `cfg_whoami` - `cfg_init` - `cfg_identity_hash` for setup only. Prefer the local CLI for real tokens because MCP clients may log tool inputs. +- `cfg_check_update` — check PyPI for a newer cfgit; pass `snooze_days` to snooze. Never upgrades. -Every MCP tool returns the same envelope shape as the CLI exit status: `status`, `code`, `message`, `data`. +Every MCP tool returns the same envelope: `status`, `code`, `message`, `data`, plus top-level `state` and `next`. `state` echoes the outcome's terminal state (e.g. `changed_outside_cfgit`, `noop`, `blocked`, `would_commit`); `next` is a self-teaching remedy `{why, remedy, commands}` when the outcome needs one, else null. Branch on `state` and follow `next.commands` on a refusal instead of guessing. For MCP bulk commits, pass `items` as structured JSON (`[{record, doc}]`) when the client supports it; use a JSON string only when the client cannot send nested objects cleanly. For MCP history/env mismatches, branch on `status="error"` and `code=6`; surface the `message` to the user and do not continue mutation. From 19dae991e8066dd243d4757701b64ca2a8d838b9 Mon Sep 17 00:00:00 2001 From: AusafMo Date: Sun, 26 Jul 2026 21:38:54 +0530 Subject: [PATCH 3/3] test(update): lock the CLI nudge-gating contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parametrized test proving the deterministic nudge fires for interactive-human read commands but is silent on --json, piped/non-TTY, and write commands — and never touches stdout in any case. Was verified live; now a permanent test so it can't silently regress. 155 pass. --- tests/test_update_check.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/test_update_check.py b/tests/test_update_check.py index f47982b..816e99c 100644 --- a/tests/test_update_check.py +++ b/tests/test_update_check.py @@ -153,3 +153,37 @@ def test_excerpt_caps_lines_and_strips_headers(): out = update._excerpt(body) assert out.count("\n") + 1 == update._NOTES_MAX_LINES # capped assert "Highlights" not in out + + +# --- CLI nudge gating (the safety contract: humans see it, scripts never do) ---------------- + + +@pytest.mark.parametrize( + "cmd,json_mode,isatty,expect_nudge", + [ + ("status", False, True, True), # interactive human read cmd → nudge + ("doctor", False, True, True), # another read cmd → nudge + ("status", False, False, False), # piped (non-TTY) → silent (scripts unaffected) + ("status", True, True, False), # --json → silent (agent JSON unpolluted) + ("commit", False, True, False), # write command → never (not in front of a mutation) + ("set", False, True, False), # write command → never + ], +) +def test_cli_nudge_gating(monkeypatch, capsys, cmd, json_mode, isatty, expect_nudge): + import types + + from cfg.cli import main as m + + # force an update to be "available" so the nudge WOULD fire if allowed. `_maybe_update_nudge` + # does `from cfg import update` internally, so patching update.check on the module suffices. + monkeypatch.setattr( + update, "check", + lambda *a, **k: types.SimpleNamespace(message="cfgit 9.9.9 is available!"), + ) + monkeypatch.setattr(m.sys.stdout, "isatty", lambda: isatty, raising=False) + + m._maybe_update_nudge(types.SimpleNamespace(cmd=cmd, json=json_mode)) + + captured = capsys.readouterr() + assert ("available" in captured.err) is expect_nudge + assert captured.out == "" # the nudge must NEVER touch stdout, in any case