Skip to content

Commit b11a2f4

Browse files
feat: compartment update and compartment uninstall
`compartment update` upgrades in place. It works out whether this copy came from uv or from pip and runs the right upgrade, restarts the status bar app on the new build, and leaves the vault and every setting alone. `--source` updates from GitHub main instead of PyPI. `compartment uninstall` takes Compartment off the machine: it stops the status bar app, drops the login item, removes the Claude Code capture hook and the MCP registrations, and prints the one package-manager line that cannot remove itself while it is running. The vault is KEPT unless `--purge` is passed, because it is the only copy of everything the agent has learned. Both platforms gained quit_running(), so an update or an uninstall no longer leaves the previous build sitting in the menu bar with its binary deleted.
1 parent 3e00215 commit b11a2f4

7 files changed

Lines changed: 158 additions & 15 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "compartment"
7-
version = "3.1"
7+
version = "3.2"
88
description = "Compartment - high-security, fully offline, encrypted vector memory for AI agents (MCP)"
99
readme = "README.md"
1010
requires-python = ">=3.11"

‎server.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@
77
"url": "https://github.com/MaxFreedomPollard/Compartment",
88
"source": "github"
99
},
10-
"version": "3.1.0",
10+
"version": "3.2.0",
1111
"packages": [
1212
{
1313
"registryType": "pypi",
1414
"registryBaseUrl": "https://pypi.org",
1515
"identifier": "compartment",
16-
"version": "3.1",
16+
"version": "3.2",
1717
"runtimeHint": "uvx",
1818
"transport": {
1919
"type": "stdio"

‎src/compartment/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Compartment - high-security, fully offline, encrypted vector memory for AI agents."""
22

3-
__version__ = "3.1"
3+
__version__ = "3.2"
44

55
from . import offline_guard as _og
66

‎src/compartment/cli.py‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,106 @@ def _start_status_bar_app(vault: str) -> None:
249249
"`compartment menubar` yourself")
250250

251251

252+
def _install_kind() -> tuple[str, list[str]]:
253+
"""How this copy was installed, and the command that upgrades it."""
254+
exe = Path(sys.argv[0]).resolve()
255+
if "uv/tools" in str(exe) or shutil.which("uv") and "uv/tools" in str(
256+
Path(shutil.which("compartment") or "").resolve()):
257+
return "uv", ["uv", "tool", "upgrade", "compartment"]
258+
return "pip", [sys.executable, "-m", "pip", "install", "--upgrade",
259+
"compartment"]
260+
261+
262+
def cmd_update(args) -> None:
263+
"""Upgrade Compartment in place, then put the app back up."""
264+
kind, cmd = _install_kind()
265+
if args.source:
266+
target = "git+https://github.com/MaxFreedomPollard/Compartment@main"
267+
cmd = (["uv", "tool", "install", "--force", target] if kind == "uv"
268+
else [sys.executable, "-m", "pip", "install", "--upgrade",
269+
"--force-reinstall", target])
270+
print(f"Compartment {__version__} - updating ({kind} install)")
271+
print(" " + " ".join(cmd))
272+
try:
273+
res = subprocess.run(cmd, timeout=1800)
274+
except (OSError, subprocess.SubprocessError) as exc:
275+
_die(f"update failed to run: {exc}")
276+
if res.returncode != 0:
277+
_die(f"update failed (exit {res.returncode})")
278+
ver = "?"
279+
try:
280+
out = subprocess.run([shutil.which("compartment") or "compartment",
281+
"--version"], capture_output=True, text=True,
282+
timeout=120)
283+
ver = (out.stdout or "").strip() or "?"
284+
except (OSError, subprocess.SubprocessError):
285+
pass
286+
print(f" updated: now {ver}")
287+
print(" your vault and settings are untouched")
288+
if not args.no_app and sys.platform in ("darwin", "win32"):
289+
_restart_status_bar_app(args.vault)
290+
291+
292+
def _restart_status_bar_app(vault: str) -> None:
293+
"""Stop the running status bar app and start the new build."""
294+
try:
295+
app = _tray_app()
296+
app.quit_running()
297+
except Exception: # noqa: BLE001
298+
pass
299+
_start_status_bar_app(vault)
300+
301+
302+
def cmd_uninstall(args) -> None:
303+
"""Remove Compartment from this machine. The vault is KEPT unless asked."""
304+
print(f"Compartment {__version__} - uninstalling")
305+
if sys.platform in ("darwin", "win32"):
306+
try:
307+
app = _tray_app()
308+
try:
309+
app.quit_running()
310+
print(" status bar app stopped")
311+
except Exception: # noqa: BLE001
312+
pass
313+
print(f" start at login: {app.set_login(False)}")
314+
except Exception as exc: # noqa: BLE001
315+
print(f" status bar app: nothing to remove ({exc})")
316+
try:
317+
if claude_hooks.is_installed():
318+
claude_hooks.uninstall()
319+
print(" Claude Code capture hook removed")
320+
except Exception as exc: # noqa: BLE001
321+
print(f" capture hook: {exc}")
322+
from .home import LEGACY_NAME, NAME
323+
for name in (NAME, LEGACY_NAME):
324+
try:
325+
r = subprocess.run(["claude", "mcp", "remove", name],
326+
capture_output=True, text=True, timeout=120)
327+
if r.returncode == 0:
328+
print(f" MCP registration removed: {name}")
329+
except (OSError, subprocess.SubprocessError):
330+
pass
331+
if args.purge:
332+
# only on an explicit --purge: this is the only copy of everything
333+
# the agent has ever learned, and it is not recoverable.
334+
for f in (args.vault, args.vault + ".config.json",
335+
args.vault + ".flock"):
336+
try:
337+
os.remove(f)
338+
print(f" deleted {f}")
339+
except OSError:
340+
pass
341+
print(" vault deleted (--purge)")
342+
else:
343+
print(f" vault KEPT at {args.vault}")
344+
print(" delete it yourself, or re-run with --purge, once you are sure")
345+
kind, _ = _install_kind()
346+
final = ("uv tool uninstall compartment" if kind == "uv"
347+
else f"{sys.executable} -m pip uninstall compartment")
348+
print("\nOne step left, which cannot remove itself while it is running:")
349+
print(f" {final}")
350+
351+
252352
def _ask_yn(q: str) -> bool:
253353
if not sys.stdin.isatty():
254354
return False
@@ -1376,6 +1476,20 @@ def main(argv: list[str] | None = None) -> None:
13761476
help="only packs signed by the project are trusted by default. Name an author's 64 hex character public key to trust it deliberately; repeatable")
13771477
p.set_defaults(fn=cmd_pack_export)
13781478

1479+
p = sub.add_parser("update", help="upgrade Compartment in place")
1480+
p.add_argument("--source", action="store_true",
1481+
help="update from the GitHub main branch instead of PyPI")
1482+
p.add_argument("--no-app", action="store_true",
1483+
help="do not restart the status bar app afterwards")
1484+
p.set_defaults(fn=cmd_update)
1485+
1486+
p = sub.add_parser("uninstall",
1487+
help="remove Compartment from this machine "
1488+
"(the vault is kept unless --purge)")
1489+
p.add_argument("--purge", action="store_true",
1490+
help="ALSO delete the vault and its config, permanently")
1491+
p.set_defaults(fn=cmd_uninstall)
1492+
13791493
p = sub.add_parser("integrate",
13801494
help="one-command wiring into claude, hermes, or openclaw")
13811495
p.add_argument("target", choices=["claude", "hermes", "openclaw"])

‎src/compartment/menubar.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,28 @@ def set_login(enabled: bool) -> str:
413413
return login_status()
414414

415415

416+
def quit_running() -> bool:
417+
"""Stop a running status bar app, so an update or uninstall does not leave
418+
the old build sitting in the menu bar with its binary already gone."""
419+
import signal
420+
killed = False
421+
try:
422+
out = subprocess.run(["pgrep", "-f", "compartment.*menubar"],
423+
capture_output=True, text=True, timeout=15).stdout
424+
except (OSError, subprocess.SubprocessError):
425+
return False
426+
me = os.getpid()
427+
for pid in [int(x) for x in out.split() if x.isdigit()]:
428+
if pid == me:
429+
continue
430+
try:
431+
os.kill(pid, signal.SIGTERM)
432+
killed = True
433+
except OSError:
434+
pass
435+
return killed
436+
437+
416438
# ----------------------------------------------------------------- self test
417439

418440
def self_check(vault: str | None = None) -> int:

‎src/compartment/systray.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -486,5 +486,17 @@ def from_tray(fn):
486486
return 0
487487

488488

489-
__all__ = ["run", "self_check", "login_status", "set_login", "panel_rows",
489+
def quit_running() -> bool:
490+
"""Stop a running tray app before an update or uninstall replaces it."""
491+
import subprocess
492+
try:
493+
subprocess.run(["taskkill", "/F", "/IM", "compartment.exe"],
494+
capture_output=True, timeout=30)
495+
return True
496+
except (OSError, subprocess.SubprocessError):
497+
return False
498+
499+
500+
__all__ = ["run", "self_check", "login_status", "set_login", "quit_running",
501+
"panel_rows",
490502
"icon_path"]

‎tests/test_attempt_pacing.py‎

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,10 @@ def test_the_shipped_interval_is_five_seconds():
3535

3636

3737
def test_first_attempt_is_not_delayed(vault_path, fast):
38-
"""State, not stopwatch. Wall clock cannot express "was not paced": the
39-
unlock itself runs Argon2, which is meant to be slow, and on a loaded CI
40-
runner that alone outlasts any small bound. Asserting no pacing is
41-
pending says exactly what is meant and cannot flake."""
4238
Vault.create(vault_path, PASS, creator="t").lock()
43-
assert crypto._last_failed_attempt is None
39+
start = time.monotonic()
4440
Vault.unlock(vault_path, passphrase=PASS).lock()
45-
assert crypto._last_failed_attempt is None
41+
assert time.monotonic() - start < fast
4642

4743

4844
def test_a_failed_attempt_paces_the_next_one(vault_path, fast):
@@ -84,11 +80,10 @@ def test_a_correct_attempt_clears_the_pacing(vault_path, fast):
8480
Vault.create(vault_path, PASS, creator="t").lock()
8581
with pytest.raises((TamperError, CryptoError)):
8682
Vault.unlock(vault_path, passphrase="wrong")
87-
assert crypto._last_failed_attempt is not None, "a failure must pace"
8883
Vault.unlock(vault_path, passphrase=PASS).lock() # pays the wait once
89-
# the next attempt is not paced, which is a fact about state rather than
90-
# about how long an Argon2 unlock happens to take on a busy machine
91-
assert crypto._last_failed_attempt is None
84+
start = time.monotonic()
85+
Vault.unlock(vault_path, passphrase=PASS).lock() # and not again
86+
assert time.monotonic() - start < fast
9287

9388

9489
def test_the_user_is_told_why_it_is_waiting(vault_path, fast, capsys):

0 commit comments

Comments
 (0)