From 0f7403f261dc18a084d7c0368a6b47481a1e6889 Mon Sep 17 00:00:00 2001 From: tcsenpai Date: Tue, 19 May 2026 11:52:46 +0200 Subject: [PATCH 01/16] chore: ignore .mycelium/ and uv.lock; untrack mycelium DB --- .gitignore | 6 ++++++ .mycelium/mycelium.db | Bin 118784 -> 0 bytes 2 files changed, 6 insertions(+) delete mode 100644 .mycelium/mycelium.db diff --git a/.gitignore b/.gitignore index ef93434..b0e7685 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,12 @@ PROJECT_INDEX.md # Hindsight migration markers .hindsight-migrate-declined +# Mycelium project tracker (local-only) +.mycelium/ + +# uv lockfile (not tracked for applications-as-libraries) +uv.lock + # Python __pycache__/ *.pyc diff --git a/.mycelium/mycelium.db b/.mycelium/mycelium.db deleted file mode 100644 index d13694460828841f1f6108e27d8631e4ea85825a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 118784 zcmeI4&u<&amB-ooLCTb5+MdyLlHJ7RH58GsB$Lumlst=MHFA4KuuRQJ6ks0)JuR|C zbxd~C-ObqA0EHVi4pemZT8ds@aM5^ zg8i#Rp5q=z5{v)X8t1~9KXaLHWd1z$mywl;U!{ML`hN23x?m=QllLzd=32eeQ~1$&DRty`quI`iEN@5m_Xg zCXtU>Bb()l_K~(jKH1rPvcB_-Jl39(^}WjWW|`G~qLnN2QVq}a8kAJDrxjA(Wd()d8Ta%F}ajA%xk-HeezIyw7$1hA#-+%T61!RmSftE=^e#YY0{e6ZuV57 zEwieJeUmjBu4~pUN@E3TZK~G{HV%0&<+@devVxkC^W3ai%aQFe!?mR`tL|$3@Jp&9U6s^z^W#(o7SA;^ zDRpg5IZl~YjlME#uXOs#qmE@XbcY_eI(I55=SIipHp>sSr=))c-U+EvmaAd*$Q>uH zr__yE<#;xDUyU-Ss8OqGQdjpAu5PoOFgtVRozc=rCH~@QQ8v^kdcvDvPN&ooYr$y$ z7VwrvRa_tZZZ3Z2t=-=4O(oTWs=Nr^#0y-#>+&KseMY}5#FdMjY`0o&dormmsEXO6 z)*pI8b}B|`XLJTC{dDeWTD0?JkHW4O36+yED!W2qJG(g?_SUtenp2f}k7n-fLTfrk zD;Eegc1O&~XA>!PZcaHp5`j>1F)4Aaqt*|MQqkoDr0>c>TBd$ve-B4#J%7NQoMn0zlWo+!>F)y=zcwHu~I4M%s6tg5~gNxWBl_oCGbk!mw+ zl{LXvTA$xas>^qjA4$PNw``A14BUT(UyI~BeZxjXv>QA8tS+PnFdgqREIv1>@ ze2Gx?Ey=mV{I}%VRon8YHE{Lu=AAioqs<#4ejAcWxHal*>R@x(&Ax{41-dqH8UDJP zJ9frW>h0Ug>t(^dJ4?91!?;8~$i6gq%V-Xau(WBIjXrC^y6GLZ_w}~RmqQ&D@`#MH zM9A-x(WH9sp7Km|xibu1`l8LFeot~t>V8jQmbYM@)y$pz-H6x>oZb~W#llhi8kOpc ztV^SN+na$npfdKj<#&kvct%O7cke34F9amLY1SQs?^xW(T2m;e(6Cz|K0I;xt09d z%4)GAHx@6KUtVE?9l6n1xx(ridv*7{BLn0L`O?~QN1qZew~}9ZKfmV7@%=yh(jOlX z009sH0T2KI5C8!X009sH0T2LzAtu20|4QcT1p5yk5C8!X009sH0T2KI5C8!X009sH zfngyqt&HAQ4(vw5e%Wrhqxk-RSPYAPfB*=900@8p2!H?xfB*=900@9UJOS+gfB*=900@8p2!H?xfB*=9 z00@A(7VS*3{fB*=9 z00@8p2!H?xfB*=900>-Z0@LYVCnm45{)L+KFocLAx2dVETzfS%#`DpyB z@zZf->=#pCPX2iEJLx~Bzes;^rMrv9|GyA8eKwv_RaN=r-qwP$2~ud=;aX0@Ma<;uKN!!x}G zB^B*yg_O71&)(LSEKs9v)iGP1X7coZX_NUgjdnwngI+PXEbi>yMKu zb#_+y>8{{gqbyw4SOZP!MxRp>-Dt^Mg6qH1)on}f^fj2}`p;Q!9^!y_f3~$3wT!HE zue|x`o|f(Oc%Jmp8=A6|N~(`%m4pcE?u&-$QQc^Jwn*zyo9TsUYU28aGLk6HB-PEk zakU$!MGZ%HkF2V`6iK{SeD|W&3Xy6viG=bezEJx7R#IKQqx>kCibPjMFcWqTlkapq zaI^eSdrJ7M6_$xdK8=Q1U1qtH#hWSh&K>1+E||`Ap+A(pwQ*7z5zDQgr2Htur)Duf zk4kesouM%JQH2+3`3W)*_TEAp5&O+ z{hq=sZ^1mPnPY=xmTv}5@4`EK7U?1O;~6ET-o2|FzYvi0rdf9kvEGS1j!3{G1w9`3 zL+ZGEv(slICA)*sYBkJ0+k&uN{O|v7jlYw~{4Vn}le+%f>)Q0cPw!4oO#WfAeQj>y zzb5`|;^UN>{MT`h3E=|*AOHfxO+f!vQZ3$Aj)Fzsf!%1>FWW6w_s^1o^tU_lI$ZPx zMIBMT>I?F+e)ZhR_r59C9jA}QTo9B84}UAo13`ArED)YHUGyYv->&tXYRS_@c+w{4 z`|Xy`p9UVGRU5Y3b|^crG2Fg~a&hYv`Mq3CID3VY*5}{oS(b|<5Pwq2|HO-qs#J8B7FLmWTY_v(pD0xBF2r`K-!ka&P`;a^x(VD(V?_a@4Q>u z4)VO-W4kjOb^E=PT4Kwao584w?5A|ob=x$O@y#=4i4^+M+%S>MZ0A{M;ztQ@cSCsk z_#Lr)``Kq=$VM6#d3;y$h)BVuU7}%E+3}~?5}xZAJ)j0TNRKFUC-cChPM@7e!*D&Z zy$*+C%rTRY^xJ!AxQ7E@&y_l~$<7A#^JK-=zGgbK8XWTn4G2TgzU>|M9L!7ZEpe2b zoU@H_a(Pg7&OBsI{YCskiNIqc@$0YSSfTf;8vo$o($0D@H?6*r_|oZZsCz9SZ*S#w2^c z?K=0x%>(!MO)JNwYQ(6qAF{9@vdKZis57}kyGieR?27JaR8oYCGhgB3eXI+v?~c*{a=anjTqPT+A)ymsS??#f3ttl3#nUlz)(4 zTFk${^#1bN>T2m;e(6Cz-y>3FS&OAoeyzBcUtRV^P97@h#7T(}+1TCvkb3O;U(&Gc z=cZL>e`U=soF-Y(a+cpT*~P%5YP5`flU*sv?gb_MQc2be&%Y`n_kbKYc9ZNI4&7(H zcL}>nVl1pY$QKt&YbC~RbuB*t zV=-S`DwLL2eG%rizZZ^`*iF1)zv9o#c^ZL50nc!W6z({cQS4VV9;l0(9LnyX}E%<;Mj-ivv{ zT)EQ<%7zDXJ(hY&*b_OVjTV=suV}T+ZtLcGVJmrO_)uV@lDh|YIR6!_x-05_Aku%& zZ%+K4(8~HNZYTeMw5{jNBQ4@GEzG|?`#H4;bsXDqeJ?sR*=0ZRLyh^!`^$@kl_DQ& zYsCRRvQk*h7mCHsP~+eKk7oXq$ozNaEA|gQAOHd&00JNY0w4eaAOHd&00JNY0#}y6 V#ORE|&m=~>4 Date: Tue, 19 May 2026 11:52:53 +0200 Subject: [PATCH 02/16] feat(notify): desktop notification on watcher 'errored' transition Add bgo_cli._notify with a zero-dep, best-effort notifier that shells out to notify-send (Linux), osascript or terminal-notifier (macOS), or a user-supplied BGO_NOTIFY_CMD override. Gating env BGO_NOTIFY chooses off | errors (default) | all. Wire _notify_errored() into cmd_watcher_loop at all three errored transitions (mode=stop, backoff exhausted, restart failure). Fix latent bug in bgo_cli/__init__.py: importlib.machinery was used without being imported, breaking dev-tree invocation. --- bgo | 155 ++++++++++++++++++++++++++++++- src/bgo_cli/__init__.py | 1 + src/bgo_cli/_notify.py | 165 +++++++++++++++++++++++++++++++++ tests/test_notify.py | 198 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 516 insertions(+), 3 deletions(-) create mode 100644 src/bgo_cli/_notify.py create mode 100644 tests/test_notify.py diff --git a/bgo b/bgo index b824f39..f7fe076 100755 --- a/bgo +++ b/bgo @@ -18,6 +18,24 @@ import tty from datetime import datetime, timezone from pathlib import Path + +def _notify_errored(name: str, reason: str) -> None: + """Best-effort desktop notification on errored transition. + + Lazy-imports the optional ``bgo_cli._notify`` module so the bare + ``bgo`` script keeps working even outside its installed package + context. Any failure is swallowed — notifications must never break + the watcher loop. + """ + try: + from bgo_cli._notify import notify + except ImportError: + return + try: + notify(f"bgo: {name} errored", reason, level="error") + except Exception: + pass + # Config BGO_DIR = Path.home() / ".bgo" PROCS_DIR = BGO_DIR / "procs" @@ -602,26 +620,30 @@ def cmd_watcher_loop(name: str) -> int: watcher_log(name, f"fast-crash: uptime={uptime:.2f}s mode={mode}") if mode == "stop": + reason = f"fast-crash (uptime {uptime:.2f}s, mode=stop)" info["watch"]["errored"] = True - info["watch"]["error_reason"] = f"fast-crash (uptime {uptime:.2f}s, mode=stop)" + info["watch"]["error_reason"] = reason info["watch"]["last_stderr_tail"] = tail info["watch"]["watcher_pid"] = None info["watch"]["watcher_pgid"] = None info["status"] = "stopped" save_proc(name, info) watcher_log(name, "errored; exiting") + _notify_errored(name, reason) return 0 if mode == "backoff": if backoff_idx >= len(BACKOFF_SCHEDULE): + reason = f"{len(BACKOFF_SCHEDULE) + 1} consecutive fast-crashes" info["watch"]["errored"] = True - info["watch"]["error_reason"] = f"{len(BACKOFF_SCHEDULE) + 1} consecutive fast-crashes" + info["watch"]["error_reason"] = reason info["watch"]["last_stderr_tail"] = tail info["watch"]["watcher_pid"] = None info["watch"]["watcher_pgid"] = None info["status"] = "stopped" save_proc(name, info) watcher_log(name, "backoff exhausted; errored; exiting") + _notify_errored(name, reason) return 0 wait = BACKOFF_SCHEDULE[backoff_idx] watcher_log(name, f"backoff sleep {wait}s (step {backoff_idx + 1}/{len(BACKOFF_SCHEDULE)})") @@ -651,6 +673,7 @@ def cmd_watcher_loop(name: str) -> int: info["watch"]["watcher_pgid"] = None info["status"] = "stopped" save_proc(name, info) + _notify_errored(name, err) return 0 now_iso = datetime.now(timezone.utc).isoformat() @@ -1697,6 +1720,98 @@ def cmd_delete(args): return 0 +# --- Optional feature handlers --- + + +def cmd_autostart(args): + """Install / uninstall / inspect login autostart for ``bgo resurrect``. + + Subcommands (``args.action``): + install — write the unit and enable it. + uninstall — disable and remove the unit. + status — print install state as table or JSON. + + The ``--tray`` flag pivots all three actions to the tray entry + instead of the resurrect entry. Both can be installed; they're + independent. + """ + try: + from bgo_cli import _autostart + except ImportError as exc: + print(f"{color('red', '❌')} autostart module unavailable: {exc}") + return 1 + + target = "tray" if getattr(args, "tray", False) else "resurrect" + action = args.action + + if action == "status": + s = _autostart.status() + if getattr(args, "json", False): + print(json.dumps(s, indent=2)) + return 0 + if s["backend"] is None: + print(f"{color('yellow', '⚠')} autostart not supported on this platform") + return 1 + print(f"backend: {s['backend']}") + for key in ("resurrect", "tray"): + mark = color("green", "✓") if s[key] else color("gray", "·") + path = s.get(f"{key}_path") or "(not installed)" + print(f"{mark} {key:<10} {path}") + return 0 + + if action == "install": + ok, msg = _autostart.install(target) + if not ok: + print(f"{color('red', '❌')} install failed: {msg}") + return 1 + print(f"{color('green', '✅')} installed {target} autostart at {msg}") + if target == "resurrect" and _autostart.detect_backend() == "systemd-user": + print(f" {color('gray', 'hint: run')} " + f"loginctl enable-linger $USER " + f"{color('gray', 'to run before login')}") + return 0 + + if action == "uninstall": + ok, msg = _autostart.uninstall(target) + if not ok: + print(f"{color('red', '❌')} uninstall failed: {msg}") + return 1 + print(f"{color('green', '✅')} uninstalled {target} autostart") + return 0 + + print(f"{color('red', '❌')} unknown autostart action: {action}") + return 1 + + +def cmd_tray(args): + """Launch the system-tray icon (optional extra ``bgo-cli[tray]``). + + If pystray/Pillow are not importable, delegates to + ``_tray_install.ensure_installed`` which detects the install + context (uv tool / pipx / pip) and offers to inject the deps. + """ + try: + from bgo_cli import _tray + except ImportError: + try: + from bgo_cli import _tray_install + except ImportError as exc: + print(f"{color('red', '❌')} tray module unavailable: {exc}") + return 1 + ok = _tray_install.ensure_installed( + auto=getattr(args, "auto_install", False) + or os.environ.get("BGO_TRAY_AUTOINSTALL") == "1" + ) + if not ok: + return 1 + # Re-exec self with the same args so the freshly-installed + # deps are picked up by a clean interpreter. + os.execvp(sys.argv[0], sys.argv) + return 0 # unreachable + + return _tray.run(poll_seconds=getattr(args, "poll", None)) + + # --- CLI --- @@ -1710,7 +1825,7 @@ def main(): known_commands = { "start", "stop", "restart", "restart-stopped", "restart-last", "status", "logs", "clean", "delete", "resurrect", - "watch", "unwatch", + "watch", "unwatch", "autostart", "tray", "ls", "list", "follow", "tail", "-h", "--help", "open", "kill", "rm", } @@ -2001,6 +2116,38 @@ Watch mode (auto-restart on crash): help="Preserve out/err/watcher log files after delete", ) + # autostart + autostart_parser = subparsers.add_parser( + "autostart", + help="Install login autostart for `bgo resurrect` (and optionally tray)", + ) + autostart_sub = autostart_parser.add_subparsers( + dest="action", required=True, help="Autostart action" + ) + for act in ("install", "uninstall"): + sp = autostart_sub.add_parser(act, help=f"{act} the autostart entry") + sp.add_argument( + "--tray", action="store_true", + help="Target the tray autostart entry instead of resurrect", + ) + sp_status = autostart_sub.add_parser("status", help="Show install state") + sp_status.add_argument( + "--json", action="store_true", help="Machine-readable output", + ) + + # tray (optional extra) + tray_parser = subparsers.add_parser( + "tray", help="Launch the system-tray icon (requires bgo-cli[tray])" + ) + tray_parser.add_argument( + "--poll", type=int, default=None, + help="State poll interval in seconds (default: 3 or $BGO_TRAY_POLL)", + ) + tray_parser.add_argument( + "--auto-install", dest="auto_install", action="store_true", + help="Install pystray/Pillow without confirmation if missing", + ) + args = parser.parse_args() if not args.command: @@ -2027,6 +2174,8 @@ Watch mode (auto-restart on crash): "rm": cmd_delete, "watch": cmd_watch, "unwatch": cmd_unwatch, + "autostart": cmd_autostart, + "tray": cmd_tray, } handler = handlers.get(args.command) diff --git a/src/bgo_cli/__init__.py b/src/bgo_cli/__init__.py index 13b2b71..1f5b532 100644 --- a/src/bgo_cli/__init__.py +++ b/src/bgo_cli/__init__.py @@ -12,6 +12,7 @@ """ from pathlib import Path +import importlib.machinery import importlib.util import sys diff --git a/src/bgo_cli/_notify.py b/src/bgo_cli/_notify.py new file mode 100644 index 0000000..6b52e29 --- /dev/null +++ b/src/bgo_cli/_notify.py @@ -0,0 +1,165 @@ +"""Desktop notifications for bgo. + +Zero-dependency, best-effort notifier. Shells out to native binaries +on each platform; silently no-ops if no backend is reachable. This +matches bgo's design rule of never adding a runtime Python dependency +to the core package. + +Backends, in priority order: + 1. ``$BGO_NOTIFY_CMD`` — caller-provided override. Treated as a + shell-style argv whose first token is the binary. ``{title}`` + and ``{body}`` placeholders are substituted before exec. + 2. ``notify-send`` (Linux, libnotify). + 3. ``osascript`` (macOS, AppleScript ``display notification``). + 4. ``terminal-notifier`` (macOS, optional brew package). + +Gating env var ``$BGO_NOTIFY`` chooses *what* to notify about: + * ``off`` — disable entirely. + * ``errors`` — only ``level="error"`` notifications fire (default). + * ``all`` — every call fires. + +The public entrypoint is :func:`notify`. It never raises; callers can +fire-and-forget without try/except. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from typing import Literal, Sequence + +Level = Literal["info", "warn", "error"] + +_VALID_LEVELS: frozenset[str] = frozenset(("info", "warn", "error")) +_VALID_GATES: frozenset[str] = frozenset(("off", "errors", "all")) +_DEFAULT_GATE = "errors" + +# macOS AppleScript template — escaped at substitution time, not formatted +# here, because the body may contain quotes that must be re-escaped per call. +_OSASCRIPT_TEMPLATE = 'display notification "{body}" with title "{title}"' + + +def _gate() -> str: + """Return the active notification gate from ``$BGO_NOTIFY``. + + Unknown values fall back to the default (``errors``) so a typo in + the user's shell config never silently disables notifications they + expected to receive. + """ + raw = (os.environ.get("BGO_NOTIFY") or "").strip().lower() + return raw if raw in _VALID_GATES else _DEFAULT_GATE + + +def _should_fire(level: Level) -> bool: + """Return whether a notification at ``level`` passes the gate.""" + gate = _gate() + if gate == "off": + return False + if gate == "all": + return True + return level == "error" + + +def _escape_applescript(s: str) -> str: + """Escape a string for safe embedding inside an AppleScript literal. + + AppleScript uses ``\\`` as the escape char inside double-quoted + string literals. Backslashes must be doubled first, then quotes. + """ + return s.replace("\\", "\\\\").replace('"', '\\"') + + +def _resolve_backend() -> tuple[str, Sequence[str]] | None: + """Pick a notifier backend. + + Returns ``(kind, argv_template)`` where ``kind`` is one of + ``"override"``, ``"notify-send"``, ``"osascript"``, + ``"terminal-notifier"``, and ``argv_template`` is the argv with + ``{title}`` / ``{body}`` placeholders ready for substitution. + + Returns ``None`` if no backend is available. + """ + override = (os.environ.get("BGO_NOTIFY_CMD") or "").strip() + if override: + # Split lazily; user is responsible for sane quoting. We do not + # invoke a shell, so injection is bounded to argv only. + return "override", tuple(override.split()) + + if sys.platform.startswith("linux"): + if shutil.which("notify-send"): + return "notify-send", ("notify-send", "{title}", "{body}") + + if sys.platform == "darwin": + if shutil.which("osascript"): + return "osascript", ("osascript", "-e", _OSASCRIPT_TEMPLATE) + if shutil.which("terminal-notifier"): + return ( + "terminal-notifier", + ("terminal-notifier", "-title", "{title}", "-message", "{body}"), + ) + + return None + + +def _format_argv( + template: Sequence[str], title: str, body: str, kind: str +) -> list[str]: + """Substitute ``{title}`` / ``{body}`` into the argv template. + + AppleScript needs its own escaping because the placeholders sit + inside a quoted script literal, not as separate argv items. Every + other backend receives the raw values as separate argv tokens, so + no shell quoting concerns apply. + """ + if kind == "osascript": + safe_title = _escape_applescript(title) + safe_body = _escape_applescript(body) + return [tok.format(title=safe_title, body=safe_body) for tok in template] + return [tok.format(title=title, body=body) for tok in template] + + +def notify(title: str, body: str, level: Level = "info") -> bool: + """Send a desktop notification. Never raises. + + :param title: Short header line. Backends may truncate. + :param body: Longer message body. + :param level: One of ``"info"``, ``"warn"``, ``"error"``. Controls + whether the gate (``$BGO_NOTIFY``) allows the call + through. Unknown values are treated as ``"info"``. + :returns: ``True`` if a backend was found and exited 0. ``False`` + if the gate suppressed the call, no backend exists, or + the backend failed. + """ + if level not in _VALID_LEVELS: + level = "info" + if not _should_fire(level): + return False + + backend = _resolve_backend() + if backend is None: + return False + kind, template = backend + + try: + argv = _format_argv(template, title, body, kind) + except (KeyError, IndexError): + # Malformed override template — placeholders missing. + return False + + try: + result = subprocess.run( + argv, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=4, + check=False, + ) + except (subprocess.SubprocessError, OSError): + return False + return result.returncode == 0 + + +__all__ = ["notify", "Level"] diff --git a/tests/test_notify.py b/tests/test_notify.py new file mode 100644 index 0000000..168de5f --- /dev/null +++ b/tests/test_notify.py @@ -0,0 +1,198 @@ +"""Tests for ``bgo_cli._notify``. + +These tests mock platform detection (``sys.platform``), binary +discovery (``shutil.which``), and subprocess invocation. They never +fire a real notification. +""" + +from __future__ import annotations + +from unittest import mock + +import pytest + +from bgo_cli import _notify + + +# --- Gate logic ---------------------------------------------------------- + + +@pytest.mark.parametrize( + ("env", "level", "expected"), + [ + ("off", "error", False), + ("off", "info", False), + ("errors", "error", True), + ("errors", "warn", False), + ("errors", "info", False), + ("all", "info", True), + ("all", "warn", True), + ("all", "error", True), + ("", "error", True), # default == errors + ("", "info", False), + ("garbage", "info", False), # garbage -> default + ("garbage", "error", True), + ], +) +def test_should_fire_respects_gate(env: str, level: str, expected: bool) -> None: + """The gate env var controls which levels pass through.""" + with mock.patch.dict(_notify.os.environ, {"BGO_NOTIFY": env}, clear=False): + assert _notify._should_fire(level) is expected # type: ignore[arg-type] + + +# --- Backend resolution -------------------------------------------------- + + +def test_resolve_backend_override_wins() -> None: + """``$BGO_NOTIFY_CMD`` short-circuits platform detection.""" + env = {"BGO_NOTIFY_CMD": "my-notifier {title} {body}"} + with mock.patch.dict(_notify.os.environ, env, clear=False): + result = _notify._resolve_backend() + assert result is not None + kind, argv = result + assert kind == "override" + assert argv == ("my-notifier", "{title}", "{body}") + + +def test_resolve_backend_linux_notify_send() -> None: + """On Linux, ``notify-send`` is picked when present.""" + with mock.patch.dict(_notify.os.environ, {}, clear=True), \ + mock.patch.object(_notify.sys, "platform", "linux"), \ + mock.patch.object(_notify.shutil, "which", return_value="/usr/bin/notify-send"): + result = _notify._resolve_backend() + assert result is not None + kind, _ = result + assert kind == "notify-send" + + +def test_resolve_backend_macos_osascript() -> None: + """On macOS, ``osascript`` is preferred over ``terminal-notifier``.""" + def fake_which(binary: str) -> str | None: + return "/usr/bin/osascript" if binary == "osascript" else None + + with mock.patch.dict(_notify.os.environ, {}, clear=True), \ + mock.patch.object(_notify.sys, "platform", "darwin"), \ + mock.patch.object(_notify.shutil, "which", side_effect=fake_which): + result = _notify._resolve_backend() + assert result is not None + kind, _ = result + assert kind == "osascript" + + +def test_resolve_backend_macos_terminal_notifier_fallback() -> None: + """Without ``osascript``, ``terminal-notifier`` is used.""" + def fake_which(binary: str) -> str | None: + return "/opt/bin/terminal-notifier" if binary == "terminal-notifier" else None + + with mock.patch.dict(_notify.os.environ, {}, clear=True), \ + mock.patch.object(_notify.sys, "platform", "darwin"), \ + mock.patch.object(_notify.shutil, "which", side_effect=fake_which): + result = _notify._resolve_backend() + assert result is not None + kind, _ = result + assert kind == "terminal-notifier" + + +def test_resolve_backend_none_available() -> None: + """Returns ``None`` when no backend can be found.""" + with mock.patch.dict(_notify.os.environ, {}, clear=True), \ + mock.patch.object(_notify.sys, "platform", "linux"), \ + mock.patch.object(_notify.shutil, "which", return_value=None): + assert _notify._resolve_backend() is None + + +# --- Escaping ------------------------------------------------------------ + + +def test_escape_applescript_quotes_and_backslashes() -> None: + """AppleScript escaping handles both ``"`` and ``\\``.""" + assert _notify._escape_applescript('he said "hi"\\') == 'he said \\"hi\\"\\\\' + + +def test_format_argv_substitutes_placeholders() -> None: + """``{title}`` and ``{body}`` are replaced in non-AppleScript backends.""" + argv = _notify._format_argv( + ("notify-send", "{title}", "{body}"), + title="T", + body="B", + kind="notify-send", + ) + assert argv == ["notify-send", "T", "B"] + + +def test_format_argv_escapes_for_osascript() -> None: + """AppleScript backend receives pre-escaped placeholders.""" + argv = _notify._format_argv( + ("osascript", "-e", _notify._OSASCRIPT_TEMPLATE), + title='a "b"', + body="c", + kind="osascript", + ) + assert argv[0:2] == ["osascript", "-e"] + assert 'display notification "c"' in argv[2] + assert 'with title "a \\"b\\""' in argv[2] + + +# --- End-to-end public API ---------------------------------------------- + + +def test_notify_returns_false_when_gate_off() -> None: + """``BGO_NOTIFY=off`` short-circuits before backend lookup.""" + with mock.patch.dict(_notify.os.environ, {"BGO_NOTIFY": "off"}, clear=False): + assert _notify.notify("t", "b", "error") is False + + +def test_notify_returns_false_when_no_backend() -> None: + """No reachable binary => silent ``False``, no raise.""" + with mock.patch.dict(_notify.os.environ, {"BGO_NOTIFY": "all"}, clear=False), \ + mock.patch.object(_notify, "_resolve_backend", return_value=None): + assert _notify.notify("t", "b", "info") is False + + +def test_notify_runs_backend_and_returns_true_on_success() -> None: + """Backend invoked with substituted argv; rc=0 maps to ``True``.""" + completed = mock.MagicMock(returncode=0) + with mock.patch.dict(_notify.os.environ, {"BGO_NOTIFY": "all"}, clear=False), \ + mock.patch.object( + _notify, + "_resolve_backend", + return_value=("notify-send", ("notify-send", "{title}", "{body}")), + ), \ + mock.patch.object(_notify.subprocess, "run", return_value=completed) as run: + ok = _notify.notify("hello", "world", "info") + assert ok is True + run.assert_called_once() + argv = run.call_args.args[0] + assert argv == ["notify-send", "hello", "world"] + + +def test_notify_subprocess_failure_returns_false() -> None: + """Non-zero rc => ``False``.""" + completed = mock.MagicMock(returncode=1) + with mock.patch.dict(_notify.os.environ, {"BGO_NOTIFY": "all"}, clear=False), \ + mock.patch.object( + _notify, + "_resolve_backend", + return_value=("notify-send", ("notify-send", "{title}", "{body}")), + ), \ + mock.patch.object(_notify.subprocess, "run", return_value=completed): + assert _notify.notify("t", "b", "info") is False + + +def test_notify_subprocess_exception_swallowed() -> None: + """OSError from subprocess is caught; never propagates.""" + with mock.patch.dict(_notify.os.environ, {"BGO_NOTIFY": "all"}, clear=False), \ + mock.patch.object( + _notify, + "_resolve_backend", + return_value=("notify-send", ("notify-send", "{title}", "{body}")), + ), \ + mock.patch.object(_notify.subprocess, "run", side_effect=OSError("boom")): + assert _notify.notify("t", "b", "info") is False + + +def test_notify_unknown_level_treated_as_info() -> None: + """Out-of-spec level downgrades to info (gated by ``errors`` default).""" + with mock.patch.dict(_notify.os.environ, {"BGO_NOTIFY": "errors"}, clear=False): + # info doesn't pass 'errors' gate => returns False without backend + assert _notify.notify("t", "b", "garbage") is False # type: ignore[arg-type] From f6742dc9dfab1a9b0dec3110906012f5aff0b632 Mon Sep 17 00:00:00 2001 From: tcsenpai Date: Tue, 19 May 2026 11:53:08 +0200 Subject: [PATCH 03/16] feat(autostart): per-user login autostart for 'bgo resurrect' Add bgo_cli._autostart with auto-detected backend: - Linux -> systemd user unit (~/.config/systemd/user/bgo-resurrect.service) - macOS -> LaunchAgent (~/Library/LaunchAgents/sh.discus.bgo.resurrect.plist) Public API: install(target), uninstall(target), status(). Target is 'resurrect' (default) or 'tray'. Tray on Linux uses XDG desktop autostart instead of systemd. macOS uses a second LaunchAgent plist. Idempotent: re-running install overwrites; uninstall is safe when nothing is installed. Atomic file writes (tmp + os.replace). Falls back to launchctl 'load -w' when 'bootstrap' fails on older macOS or for already-loaded plists. 28 unit tests, all platform-mocked: no real autostart entries are written by the suite. --- src/bgo_cli/_autostart.py | 331 ++++++++++++++++++++++++++++++++++++++ tests/test_autostart.py | 285 ++++++++++++++++++++++++++++++++ 2 files changed, 616 insertions(+) create mode 100644 src/bgo_cli/_autostart.py create mode 100644 tests/test_autostart.py diff --git a/src/bgo_cli/_autostart.py b/src/bgo_cli/_autostart.py new file mode 100644 index 0000000..1e8cafe --- /dev/null +++ b/src/bgo_cli/_autostart.py @@ -0,0 +1,331 @@ +"""Login/boot autostart integration for bgo. + +Installs a per-user service that runs ``bgo resurrect`` on session +start, restoring every process that was registered as ``running`` at +shutdown. Optionally installs an autostart entry for the tray icon. + +Backends are detected automatically: + +* **Linux** -> systemd user manager (``systemctl --user``). The unit + is written to ``~/.config/systemd/user/`` and enabled with the + ``default.target`` Wants= link. Boot-time start without an active + login session requires ``loginctl enable-linger ``; we print + a hint rather than running it ourselves (it touches system state). + +* **macOS** -> launchd user agent. The plist is written to + ``~/Library/LaunchAgents/`` and loaded via ``launchctl bootstrap``. + ``RunAtLoad=true`` triggers on login. + +The tray target writes a separate desktop-autostart entry: + +* **Linux** -> ``~/.config/autostart/bgo-tray.desktop`` (XDG spec). +* **macOS** -> a second LaunchAgent plist for the tray binary. + +All write paths are idempotent: re-running ``install`` overwrites the +unit content but does not duplicate registrations. ``uninstall`` is +safe to call when nothing is installed. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Literal + +Target = Literal["resurrect", "tray"] +Backend = Literal["systemd-user", "launchd"] + +# --- Unit / plist templates --------------------------------------------- + +_SYSTEMD_UNIT = """\ +[Unit] +Description=bgo — restore background processes on login (resurrect) +After=default.target + +[Service] +Type=oneshot +ExecStart={bgo} resurrect +RemainAfterExit=no + +[Install] +WantedBy=default.target +""" + +_LAUNCHD_PLIST = """\ + + + + + Label{label} + ProgramArguments + + {bgo} +{extra_args} + + RunAtLoad + KeepAlive + StandardOutPath{home}/.bgo/logs/{label}.out.log + StandardErrorPath{home}/.bgo/logs/{label}.err.log + + +""" + +_XDG_DESKTOP = """\ +[Desktop Entry] +Type=Application +Name=bgo tray +Comment=Background process manager tray icon +Exec={bgo} tray +Icon=utilities-system-monitor +Terminal=false +Categories=Utility;System; +X-GNOME-Autostart-enabled=true +""" + +_LAUNCHD_LABEL_RESURRECT = "sh.discus.bgo.resurrect" +_LAUNCHD_LABEL_TRAY = "sh.discus.bgo.tray" + + +# --- Path resolution ----------------------------------------------------- + + +def detect_backend() -> Backend | None: + """Return the autostart backend for this OS, or ``None``.""" + if sys.platform.startswith("linux"): + return "systemd-user" + if sys.platform == "darwin": + return "launchd" + return None + + +def _systemd_dir() -> Path: + """Directory for user-scope systemd units.""" + xdg = os.environ.get("XDG_CONFIG_HOME") + base = Path(xdg) if xdg else Path.home() / ".config" + return base / "systemd" / "user" + + +def _xdg_autostart_dir() -> Path: + """Directory for XDG desktop-autostart entries.""" + xdg = os.environ.get("XDG_CONFIG_HOME") + base = Path(xdg) if xdg else Path.home() / ".config" + return base / "autostart" + + +def _launchd_dir() -> Path: + """Directory for per-user launchd agents.""" + return Path.home() / "Library" / "LaunchAgents" + + +def _resolve_bgo_binary() -> str: + """Locate the ``bgo`` executable to embed in the unit file. + + Falls back to ``sys.argv[0]`` only if ``shutil.which`` cannot find + the command on PATH — this happens when bgo is installed in a + sandboxed venv (e.g. ``uv tool``) whose bin dir is on the user's + PATH but not the test runner's PATH. + """ + found = shutil.which("bgo") + if found: + return found + # Fall back to the script that invoked us. resolve() collapses + # symlinks; we want the absolute, canonical path. + return str(Path(sys.argv[0]).resolve()) + + +def _path_for(backend: Backend, target: Target) -> Path: + """Return the on-disk path for the (backend, target) pair.""" + if backend == "systemd-user": + if target == "resurrect": + return _systemd_dir() / "bgo-resurrect.service" + return _xdg_autostart_dir() / "bgo-tray.desktop" + # launchd + label = _LAUNCHD_LABEL_RESURRECT if target == "resurrect" else _LAUNCHD_LABEL_TRAY + return _launchd_dir() / f"{label}.plist" + + +# --- Content rendering --------------------------------------------------- + + +def _render_systemd_unit(bgo: str) -> str: + """Render the resurrect systemd unit.""" + return _SYSTEMD_UNIT.format(bgo=bgo) + + +def _render_xdg_desktop(bgo: str) -> str: + """Render the tray XDG autostart entry.""" + return _XDG_DESKTOP.format(bgo=bgo) + + +def _render_launchd_plist(target: Target, bgo: str) -> str: + """Render a launchd plist for resurrect or tray.""" + if target == "resurrect": + label = _LAUNCHD_LABEL_RESURRECT + extra = " resurrect" + else: + label = _LAUNCHD_LABEL_TRAY + extra = " tray" + return _LAUNCHD_PLIST.format( + label=label, bgo=bgo, extra_args=extra, home=str(Path.home()) + ) + + +# --- Filesystem + service-manager wiring -------------------------------- + + +def _write_file(path: Path, content: str) -> None: + """Atomic write: create parent dirs, tmp + rename.""" + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(content) + os.replace(tmp, path) + + +def _run(argv: list[str]) -> tuple[int, str]: + """Run a command capturing combined output. Never raises.""" + try: + proc = subprocess.run( + argv, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + except (subprocess.SubprocessError, OSError) as exc: + return 127, str(exc) + return proc.returncode, (proc.stdout or "") + (proc.stderr or "") + + +def _systemd_reload_and_enable(unit: str) -> tuple[bool, str]: + """Reload the user manager and enable+start the given unit.""" + rc, out = _run(["systemctl", "--user", "daemon-reload"]) + if rc != 0: + return False, f"daemon-reload failed: {out.strip()}" + rc, out = _run(["systemctl", "--user", "enable", "--now", unit]) + if rc != 0: + return False, f"enable failed: {out.strip()}" + return True, "" + + +def _systemd_disable(unit: str) -> tuple[bool, str]: + """Disable + stop a user unit. Missing units are not an error.""" + rc, out = _run(["systemctl", "--user", "disable", "--now", unit]) + if rc != 0 and "does not exist" not in out and "No such file" not in out: + return False, f"disable failed: {out.strip()}" + return True, "" + + +def _launchctl_load(plist: Path) -> tuple[bool, str]: + """Bootstrap a per-user agent. Falls back to legacy ``load`` cmd.""" + uid = os.getuid() + rc, out = _run(["launchctl", "bootstrap", f"gui/{uid}", str(plist)]) + if rc == 0: + return True, "" + # Older macOS / already-loaded states return non-zero — try legacy + # ``load`` as a fallback so we don't error on cleanly reusable + # plists. + rc2, out2 = _run(["launchctl", "load", "-w", str(plist)]) + if rc2 == 0: + return True, "" + return False, f"bootstrap failed: {out.strip()}; load failed: {out2.strip()}" + + +def _launchctl_unload(plist: Path) -> tuple[bool, str]: + """Tear down a per-user agent. Missing agents are not an error.""" + uid = os.getuid() + _run(["launchctl", "bootout", f"gui/{uid}", str(plist)]) + _run(["launchctl", "unload", "-w", str(plist)]) + return True, "" + + +# --- Public API ---------------------------------------------------------- + + +def install(target: Target) -> tuple[bool, str]: + """Install the autostart entry for ``target``. + + :returns: ``(ok, message)`` where ``message`` is empty on success + and contains a human-readable error otherwise. + """ + backend = detect_backend() + if backend is None: + return False, f"unsupported platform: {sys.platform}" + + bgo = _resolve_bgo_binary() + path = _path_for(backend, target) + + if backend == "systemd-user": + if target == "resurrect": + _write_file(path, _render_systemd_unit(bgo)) + ok, err = _systemd_reload_and_enable("bgo-resurrect.service") + if not ok: + return False, err + return True, str(path) + # tray -> XDG desktop autostart (no systemctl needed) + _write_file(path, _render_xdg_desktop(bgo)) + return True, str(path) + + # launchd + _write_file(path, _render_launchd_plist(target, bgo)) + ok, err = _launchctl_load(path) + if not ok: + return False, err + return True, str(path) + + +def uninstall(target: Target) -> tuple[bool, str]: + """Remove the autostart entry for ``target``. Idempotent.""" + backend = detect_backend() + if backend is None: + return False, f"unsupported platform: {sys.platform}" + + path = _path_for(backend, target) + + if backend == "systemd-user": + if target == "resurrect": + ok, err = _systemd_disable("bgo-resurrect.service") + path.unlink(missing_ok=True) + if not ok: + return False, err + return True, "" + path.unlink(missing_ok=True) + return True, "" + + # launchd + if path.exists(): + _launchctl_unload(path) + path.unlink(missing_ok=True) + return True, "" + + +def status() -> dict[str, object]: + """Report the current install status of each target. + + :returns: ``{"backend": str|None, "resurrect": bool, "tray": bool, + "resurrect_path": str|None, "tray_path": str|None}``. + """ + backend = detect_backend() + if backend is None: + return { + "backend": None, + "resurrect": False, + "tray": False, + "resurrect_path": None, + "tray_path": None, + } + rp = _path_for(backend, "resurrect") + tp = _path_for(backend, "tray") + return { + "backend": backend, + "resurrect": rp.exists(), + "tray": tp.exists(), + "resurrect_path": str(rp) if rp.exists() else None, + "tray_path": str(tp) if tp.exists() else None, + } + + +__all__ = ["install", "uninstall", "status", "detect_backend", "Target", "Backend"] diff --git a/tests/test_autostart.py b/tests/test_autostart.py new file mode 100644 index 0000000..cbaa6ef --- /dev/null +++ b/tests/test_autostart.py @@ -0,0 +1,285 @@ +"""Tests for ``bgo_cli._autostart``. + +All filesystem writes target ``tmp_path``. All service-manager calls +(``systemctl``, ``launchctl``) are mocked. No real autostart entry is +ever installed on the test host. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest import mock + +import pytest + +from bgo_cli import _autostart + + +@pytest.fixture +def linux_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Pretend we're on Linux and reroute config dirs to ``tmp_path``.""" + monkeypatch.setattr(_autostart.sys, "platform", "linux") + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + monkeypatch.setattr(_autostart.Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setattr(_autostart.shutil, "which", lambda _name: "/usr/local/bin/bgo") + return tmp_path + + +@pytest.fixture +def darwin_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Pretend we're on macOS and reroute ``$HOME`` to ``tmp_path``.""" + monkeypatch.setattr(_autostart.sys, "platform", "darwin") + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setattr(_autostart.Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setattr(_autostart.shutil, "which", lambda _name: "/opt/bin/bgo") + return tmp_path + + +# --- Backend detection --------------------------------------------------- + + +def test_detect_backend_linux(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(_autostart.sys, "platform", "linux") + assert _autostart.detect_backend() == "systemd-user" + + +def test_detect_backend_darwin(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(_autostart.sys, "platform", "darwin") + assert _autostart.detect_backend() == "launchd" + + +def test_detect_backend_unsupported(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(_autostart.sys, "platform", "win32") + assert _autostart.detect_backend() is None + + +# --- Path resolution ----------------------------------------------------- + + +def test_path_systemd_resurrect(linux_env: Path) -> None: + path = _autostart._path_for("systemd-user", "resurrect") + assert path == linux_env / "config" / "systemd" / "user" / "bgo-resurrect.service" + + +def test_path_systemd_tray(linux_env: Path) -> None: + path = _autostart._path_for("systemd-user", "tray") + assert path == linux_env / "config" / "autostart" / "bgo-tray.desktop" + + +def test_path_launchd_resurrect(darwin_env: Path) -> None: + path = _autostart._path_for("launchd", "resurrect") + assert path == darwin_env / "Library" / "LaunchAgents" / "sh.discus.bgo.resurrect.plist" + + +def test_path_launchd_tray(darwin_env: Path) -> None: + path = _autostart._path_for("launchd", "tray") + assert path == darwin_env / "Library" / "LaunchAgents" / "sh.discus.bgo.tray.plist" + + +# --- Rendering ----------------------------------------------------------- + + +def test_render_systemd_unit_embeds_bgo_path() -> None: + content = _autostart._render_systemd_unit("/usr/local/bin/bgo") + assert "ExecStart=/usr/local/bin/bgo resurrect" in content + assert "[Install]" in content + assert "WantedBy=default.target" in content + + +def test_render_xdg_desktop_embeds_bgo_path() -> None: + content = _autostart._render_xdg_desktop("/opt/bgo") + assert "Exec=/opt/bgo tray" in content + assert "Type=Application" in content + + +def test_render_launchd_plist_resurrect() -> None: + content = _autostart._render_launchd_plist("resurrect", "/opt/bgo") + assert "sh.discus.bgo.resurrect" in content + assert "/opt/bgo" in content + assert "resurrect" in content + assert "RunAtLoad" in content + + +def test_render_launchd_plist_tray() -> None: + content = _autostart._render_launchd_plist("tray", "/opt/bgo") + assert "sh.discus.bgo.tray" in content + assert "tray" in content + + +# --- Atomic write -------------------------------------------------------- + + +def test_write_file_creates_parents_and_content(tmp_path: Path) -> None: + target = tmp_path / "a" / "b" / "c.txt" + _autostart._write_file(target, "hello\n") + assert target.read_text() == "hello\n" + + +def test_write_file_overwrites(tmp_path: Path) -> None: + target = tmp_path / "f" + _autostart._write_file(target, "first") + _autostart._write_file(target, "second") + assert target.read_text() == "second" + + +# --- install / uninstall (linux) ---------------------------------------- + + +def test_install_resurrect_linux_writes_unit_and_enables(linux_env: Path) -> None: + calls: list[list[str]] = [] + + def fake_run(argv: list[str]) -> tuple[int, str]: + calls.append(argv) + return 0, "" + + with mock.patch.object(_autostart, "_run", side_effect=fake_run): + ok, msg = _autostart.install("resurrect") + + assert ok, msg + unit = linux_env / "config" / "systemd" / "user" / "bgo-resurrect.service" + assert unit.exists() + assert "ExecStart=/usr/local/bin/bgo resurrect" in unit.read_text() + assert calls == [ + ["systemctl", "--user", "daemon-reload"], + ["systemctl", "--user", "enable", "--now", "bgo-resurrect.service"], + ] + + +def test_install_resurrect_linux_propagates_systemctl_failure(linux_env: Path) -> None: + with mock.patch.object( + _autostart, "_run", return_value=(1, "boom") + ): + ok, msg = _autostart.install("resurrect") + assert ok is False + assert "daemon-reload failed" in msg + + +def test_install_tray_linux_writes_desktop_entry_no_systemctl(linux_env: Path) -> None: + with mock.patch.object(_autostart, "_run") as run: + ok, msg = _autostart.install("tray") + assert ok, msg + run.assert_not_called() + desktop = linux_env / "config" / "autostart" / "bgo-tray.desktop" + assert desktop.exists() + assert "Exec=/usr/local/bin/bgo tray" in desktop.read_text() + + +def test_uninstall_resurrect_linux_removes_unit(linux_env: Path) -> None: + unit = linux_env / "config" / "systemd" / "user" / "bgo-resurrect.service" + unit.parent.mkdir(parents=True, exist_ok=True) + unit.write_text("dummy") + + with mock.patch.object(_autostart, "_run", return_value=(0, "")): + ok, msg = _autostart.uninstall("resurrect") + + assert ok, msg + assert not unit.exists() + + +def test_uninstall_tray_linux_removes_desktop_entry(linux_env: Path) -> None: + desktop = linux_env / "config" / "autostart" / "bgo-tray.desktop" + desktop.parent.mkdir(parents=True, exist_ok=True) + desktop.write_text("dummy") + + ok, _ = _autostart.uninstall("tray") + assert ok + assert not desktop.exists() + + +def test_uninstall_idempotent_when_missing(linux_env: Path) -> None: + with mock.patch.object(_autostart, "_run", return_value=(0, "")): + ok, _ = _autostart.uninstall("resurrect") + assert ok + + +# --- install / uninstall (darwin) --------------------------------------- + + +def test_install_resurrect_darwin_writes_plist_and_bootstraps(darwin_env: Path) -> None: + with mock.patch.object(_autostart, "_run", return_value=(0, "")) as run: + ok, msg = _autostart.install("resurrect") + assert ok, msg + plist = darwin_env / "Library" / "LaunchAgents" / "sh.discus.bgo.resurrect.plist" + assert plist.exists() + # First call should be the bootstrap; we don't check the exact uid + # because os.getuid() varies in CI. + first = run.call_args_list[0].args[0] + assert first[0:2] == ["launchctl", "bootstrap"] + assert first[-1] == str(plist) + + +def test_install_darwin_falls_back_to_legacy_load(darwin_env: Path) -> None: + def fake_run(argv: list[str]) -> tuple[int, str]: + if argv[1] == "bootstrap": + return 1, "already loaded" + if argv[1] == "load": + return 0, "" + return 0, "" + + with mock.patch.object(_autostart, "_run", side_effect=fake_run): + ok, msg = _autostart.install("resurrect") + assert ok, msg + + +def test_install_darwin_reports_both_failures(darwin_env: Path) -> None: + with mock.patch.object(_autostart, "_run", return_value=(1, "nope")): + ok, msg = _autostart.install("resurrect") + assert ok is False + assert "bootstrap failed" in msg + assert "load failed" in msg + + +def test_uninstall_darwin_removes_plist(darwin_env: Path) -> None: + plist = darwin_env / "Library" / "LaunchAgents" / "sh.discus.bgo.tray.plist" + plist.parent.mkdir(parents=True, exist_ok=True) + plist.write_text("dummy") + with mock.patch.object(_autostart, "_run", return_value=(0, "")): + ok, _ = _autostart.uninstall("tray") + assert ok + assert not plist.exists() + + +# --- status -------------------------------------------------------------- + + +def test_status_reports_nothing_installed(linux_env: Path) -> None: + s = _autostart.status() + assert s["backend"] == "systemd-user" + assert s["resurrect"] is False + assert s["tray"] is False + assert s["resurrect_path"] is None + + +def test_status_reports_installed_paths(linux_env: Path) -> None: + unit = _autostart._path_for("systemd-user", "resurrect") + unit.parent.mkdir(parents=True, exist_ok=True) + unit.write_text("x") + s = _autostart.status() + assert s["resurrect"] is True + assert s["resurrect_path"] == str(unit) + + +def test_status_unsupported_platform(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(_autostart.sys, "platform", "win32") + s = _autostart.status() + assert s["backend"] is None + assert s["resurrect"] is False + assert s["tray"] is False + + +# --- _resolve_bgo_binary -------------------------------------------------- + + +def test_resolve_bgo_binary_prefers_which(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(_autostart.shutil, "which", lambda _: "/found/bgo") + assert _autostart._resolve_bgo_binary() == "/found/bgo" + + +def test_resolve_bgo_binary_falls_back_to_argv0( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + fake = tmp_path / "bgo" + fake.touch() + monkeypatch.setattr(_autostart.shutil, "which", lambda _: None) + monkeypatch.setattr(_autostart.sys, "argv", [str(fake)]) + assert _autostart._resolve_bgo_binary() == str(fake.resolve()) From 7ee212022725c85533b7f46255a12d0bb77bc1a7 Mon Sep 17 00:00:00 2001 From: tcsenpai Date: Tue, 19 May 2026 11:53:20 +0200 Subject: [PATCH 04/16] feat(tray): optional system-tray icon with installer auto-detect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add bgo_cli._tray (pystray + Pillow glue) and bgo_cli._tray_install (auto-install fallback when the optional [tray] extra isn't present). _tray splits pure menu logic (load_snapshots, build_menu_spec, ProcSnapshot) from the pystray event loop so the testable surface doesn't depend on the optional deps. Actions shell out to the 'bgo' binary itself; no state-mutation logic is duplicated. _tray_install detects the installer (uv tool / pipx / pip) from sys.prefix and offers the matching dep-injection command: - uv -> uv tool install --upgrade --with pystray --with Pillow bgo-cli - pipx -> pipx inject bgo-cli pystray Pillow - pip -> python -m pip install --user pystray Pillow User must confirm unless --auto-install or BGO_TRAY_AUTOINSTALL=1. Aborts cleanly when the installer binary itself is missing. After a successful install, cmd_tray re-execs sys.argv so the freshly injected deps are picked up by a clean interpreter. 26 unit tests covering snapshot loading, menu rendering, poll interval resolution, subprocess dispatch, and installer detection. The pystray-bound _run_tray is intentionally not covered — it requires the optional extra to be installed. --- src/bgo_cli/_tray.py | 298 +++++++++++++++++++++++++++++++++++ src/bgo_cli/_tray_install.py | 157 ++++++++++++++++++ tests/test_tray.py | 164 +++++++++++++++++++ tests/test_tray_install.py | 179 +++++++++++++++++++++ 4 files changed, 798 insertions(+) create mode 100644 src/bgo_cli/_tray.py create mode 100644 src/bgo_cli/_tray_install.py create mode 100644 tests/test_tray.py create mode 100644 tests/test_tray_install.py diff --git a/src/bgo_cli/_tray.py b/src/bgo_cli/_tray.py new file mode 100644 index 0000000..1a9446d --- /dev/null +++ b/src/bgo_cli/_tray.py @@ -0,0 +1,298 @@ +"""System-tray icon for bgo (optional extra ``bgo-cli[tray]``). + +This module is imported lazily by ``cmd_tray`` and is the only place +``pystray`` / ``Pillow`` are referenced at runtime. Splitting the +import-bearing glue (``_run_tray``) from the pure menu construction +(``build_menu_spec``) lets us unit-test the menu without the optional +deps installed. + +UI model +======== +We poll ``~/.bgo/procs/*.json`` every ``poll_seconds`` (default 3, +overridable by ``$BGO_TRAY_POLL`` or ``--poll``). The full menu is +rebuilt from the snapshot — pystray rebuilds cheaply, and a +declarative menu avoids the bugs that come from in-place mutation. + +Actions never duplicate state-management logic from the core script. +Every Start/Stop/Restart click shells out to the ``bgo`` binary, so +behavior matches the CLI exactly and we don't risk diverging. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable, Iterable + +BGO_DIR = Path.home() / ".bgo" +PROCS_DIR = BGO_DIR / "procs" + +_DEFAULT_POLL_SECONDS = 3 + + +# --- Pure data model ----------------------------------------------------- + + +@dataclass(frozen=True) +class ProcSnapshot: + """A read-only view of one proc's state file, for menu rendering.""" + + name: str + status: str # "running" | "stopped" | "errored" | other + pid: int | None + errored: bool + + +@dataclass +class MenuSpec: + """Declarative description of the tray menu. + + The ``run`` layer translates this into ``pystray.Menu`` objects; + tests inspect the spec directly. Keeping this layer + framework-agnostic means swapping pystray for another toolkit in + the future touches only ``_run_tray``. + """ + + title: str + procs: list[ProcSnapshot] = field(default_factory=list) + actions: list[tuple[str, str]] = field(default_factory=list) + # actions is a list of (label, command) pairs for global entries + # like Resurrect-all or Quit. Per-proc actions are derived from + # ``procs`` at render time. + + +# --- Snapshot loading ---------------------------------------------------- + + +def _load_one(path: Path) -> ProcSnapshot | None: + """Parse a single proc JSON file. Returns ``None`` on any I/O error.""" + try: + info = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return None + name = info.get("name") or path.stem + status = info.get("status", "unknown") + pid = info.get("pid") + watch = info.get("watch") or {} + errored = bool(watch.get("errored")) + return ProcSnapshot( + name=name, + status=status, + pid=pid if isinstance(pid, int) else None, + errored=errored, + ) + + +def load_snapshots(procs_dir: Path = PROCS_DIR) -> list[ProcSnapshot]: + """Load every proc state file as a snapshot, sorted by name.""" + if not procs_dir.exists(): + return [] + out: list[ProcSnapshot] = [] + for pf in sorted(procs_dir.glob("*.json")): + snap = _load_one(pf) + if snap is not None: + out.append(snap) + return out + + +# --- Menu construction --------------------------------------------------- + + +def _status_label(snap: ProcSnapshot) -> str: + """Short human label for a proc's status, used in the menu.""" + if snap.errored: + return "errored" + if snap.status == "running": + return "online" + return "stopped" + + +def build_menu_spec(snapshots: Iterable[ProcSnapshot]) -> MenuSpec: + """Render snapshots into a :class:`MenuSpec` for the toolkit layer. + + Side-effect-free; safe to call in tests without pystray installed. + """ + procs = list(snapshots) + online = sum(1 for s in procs if s.status == "running" and not s.errored) + stopped = len(procs) - online + title = f"bgo — {online} online / {stopped} stopped" + actions = [ + ("Resurrect all", "resurrect"), + ("Refresh now", "__refresh__"), + ("Quit", "__quit__"), + ] + return MenuSpec(title=title, procs=procs, actions=actions) + + +# --- Subprocess actions -------------------------------------------------- + + +def _bgo_binary() -> str: + """Resolve the ``bgo`` binary to shell out to. + + Mirrors ``_autostart._resolve_bgo_binary`` but kept local to avoid + importing autostart just for one helper. + """ + found = shutil.which("bgo") + if found: + return found + return str(Path(sys.argv[0]).resolve()) + + +def run_bgo(*args: str) -> int: + """Invoke ``bgo`` with the given args. Returns the exit code. + + Output is suppressed; the tray surfaces feedback via subsequent + snapshot refreshes, not via terminal output. + """ + argv = [_bgo_binary(), *args] + try: + proc = subprocess.run( + argv, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=20, + check=False, + ) + except (subprocess.SubprocessError, OSError): + return 127 + return proc.returncode + + +def _open_logs(name: str) -> None: + """Open the proc's stdout log in ``$EDITOR`` or the OS default.""" + log = BGO_DIR / "logs" / f"{name}.out.log" + if not log.exists(): + return + editor = os.environ.get("EDITOR") + if editor: + try: + subprocess.Popen([editor, str(log)]) + return + except OSError: + pass + opener = "open" if sys.platform == "darwin" else "xdg-open" + try: + subprocess.Popen([opener, str(log)]) + except OSError: + pass + + +def _poll_interval() -> int: + """Resolve the poll interval, env var > default.""" + raw = os.environ.get("BGO_TRAY_POLL") or "" + try: + v = int(raw) + if v > 0: + return v + except ValueError: + pass + return _DEFAULT_POLL_SECONDS + + +# --- pystray glue -------------------------------------------------------- + + +def _make_icon_image(): # pragma: no cover — exercised only with Pillow + """Build a tiny B/W icon image. Imported lazily so tests don't need PIL.""" + from PIL import Image, ImageDraw + + size = 64 + img = Image.new("RGB", (size, size), "black") + draw = ImageDraw.Draw(img) + # Three horizontal bars — readable at 16px. + for y in (14, 28, 42): + draw.rectangle((10, y, size - 10, y + 8), fill="white") + return img + + +def _run_tray(poll_seconds: int) -> int: # pragma: no cover — needs pystray + """Run the pystray event loop until the user quits. + + This is the only function in the module that requires the optional + deps. Kept thin: the bulk of behavior lives in the pure helpers + above so it stays testable. + """ + import pystray + from pystray import MenuItem as Item, Menu + + quit_flag = {"stop": False} + + def make_proc_submenu(snap: ProcSnapshot) -> Menu: + return Menu( + Item("Start", lambda _icon, _it, n=snap.name: run_bgo("start", n)), + Item("Stop", lambda _icon, _it, n=snap.name: run_bgo("stop", n)), + Item("Restart", lambda _icon, _it, n=snap.name: run_bgo("restart", n)), + Menu.SEPARATOR, + Item("Open logs", lambda _icon, _it, n=snap.name: _open_logs(n)), + ) + + def build_menu() -> Menu: + spec = build_menu_spec(load_snapshots()) + items: list = [Item(spec.title, None, enabled=False), Menu.SEPARATOR] + for snap in spec.procs: + label = f"{snap.name} [{_status_label(snap)}]" + items.append(Item(label, make_proc_submenu(snap))) + items.append(Menu.SEPARATOR) + for label, cmd in spec.actions: + if cmd == "__quit__": + def _quit(icon, _item): # noqa: ANN001 + quit_flag["stop"] = True + icon.stop() + items.append(Item(label, _quit)) + elif cmd == "__refresh__": + items.append(Item(label, lambda icon, _it: icon.update_menu())) + else: + items.append( + Item(label, lambda _icon, _it, c=cmd: run_bgo(c)) + ) + return Menu(*items) + + icon = pystray.Icon( + "bgo", + icon=_make_icon_image(), + title="bgo", + menu=build_menu(), + ) + + # Periodically rebuild the menu so status reflects fresh snapshots. + import threading + + def poller() -> None: + import time + while not quit_flag["stop"]: + time.sleep(poll_seconds) + try: + icon.menu = build_menu() + icon.update_menu() + except Exception: + # Pystray internals are toolkit-specific; swallow to + # keep the loop alive on transient errors. + pass + + t = threading.Thread(target=poller, daemon=True) + t.start() + icon.run() + return 0 + + +def run(poll_seconds: int | None = None) -> int: + """Entrypoint for ``bgo tray``. Resolves poll interval then runs.""" + interval = poll_seconds if (poll_seconds and poll_seconds > 0) else _poll_interval() + return _run_tray(interval) + + +__all__ = [ + "ProcSnapshot", + "MenuSpec", + "load_snapshots", + "build_menu_spec", + "run_bgo", + "run", +] diff --git a/src/bgo_cli/_tray_install.py b/src/bgo_cli/_tray_install.py new file mode 100644 index 0000000..1e5bf69 --- /dev/null +++ b/src/bgo_cli/_tray_install.py @@ -0,0 +1,157 @@ +"""Auto-install logic for the optional ``[tray]`` extra. + +The tray UI needs ``pystray`` and ``Pillow`` — both heavy enough that +we intentionally keep them out of the default install. When the user +runs ``bgo tray`` without those deps present, this module detects how +``bgo`` itself was installed and proposes the matching install +command: + +* ``uv tool`` -> ``uv tool install --upgrade --with pystray --with Pillow bgo-cli`` +* ``pipx`` -> ``pipx inject bgo-cli pystray Pillow`` +* anything else -> ``pip install --user pystray Pillow`` + +Detection is heuristic but reliable in practice: each installer +leaves its name in ``sys.prefix`` (e.g. ``~/.local/share/uv/tools/bgo-cli``). + +The user is always asked before we run a command that mutates their +Python environment, unless ``auto=True`` (set via ``--auto-install`` +or ``$BGO_TRAY_AUTOINSTALL=1``). +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from typing import Literal + +InstallerKind = Literal["uv", "pipx", "pip"] + + +def _ansi(name: str, text: str) -> str: + """Tiny ANSI wrapper so this module stays dep-free of ``_core``. + + Falls back to plain text when stdout is not a TTY. + """ + if not sys.stdout.isatty(): + return text + codes = {"red": "31", "green": "32", "yellow": "33", "bold": "1"} + code = codes.get(name, "") + return f"\033[{code}m{text}\033[0m" if code else text + + +def detect_installer() -> InstallerKind: + """Guess which installer owns this Python environment. + + Uses ``sys.prefix`` because every installer roots bgo's venv + inside its own per-tool tree. We check the most specific markers + first (``uv tool``, ``pipx``) and fall through to plain ``pip``. + """ + prefix = (sys.prefix or "").replace("\\", "/") + lower = prefix.lower() + if "/uv/tools/" in lower or "uv-tool" in lower: + return "uv" + if "/pipx/venvs/" in lower or "/pipx/" in lower: + return "pipx" + return "pip" + + +def _command_for(installer: InstallerKind) -> list[str]: + """Return the argv that injects ``pystray`` + ``Pillow``.""" + if installer == "uv": + return [ + "uv", "tool", "install", "--upgrade", + "--with", "pystray", "--with", "Pillow", + "bgo-cli", + ] + if installer == "pipx": + return ["pipx", "inject", "bgo-cli", "pystray", "Pillow"] + return [ + sys.executable, "-m", "pip", "install", "--user", + "pystray", "Pillow", + ] + + +def _prompt_yes_no(question: str, default: bool = False) -> bool: + """Ask a y/N question on stdin. Returns ``default`` on EOF / non-TTY.""" + if not sys.stdin.isatty(): + return default + suffix = " [Y/n] " if default else " [y/N] " + try: + answer = input(question + suffix).strip().lower() + except EOFError: + return default + if not answer: + return default + return answer in ("y", "yes") + + +def _installer_available(installer: InstallerKind) -> bool: + """Check whether the proposed installer binary is on PATH.""" + if installer == "uv": + return shutil.which("uv") is not None + if installer == "pipx": + return shutil.which("pipx") is not None + # ``pip`` runs as ``python -m pip``; always available if Python is. + return True + + +def ensure_installed(auto: bool = False) -> bool: + """Make sure ``pystray`` + ``PIL`` are importable. Install if not. + + :param auto: Skip the y/N prompt and run the install command. + :returns: ``True`` once both deps import cleanly. ``False`` if + the user declined or the install command failed. + """ + try: + import pystray # noqa: F401 (presence check only) + import PIL # noqa: F401 + return True + except ImportError: + pass + + installer = detect_installer() + argv = _command_for(installer) + + print( + f"{_ansi('yellow', '⚠')} Tray requires " + f"{_ansi('bold', 'pystray')} and {_ansi('bold', 'Pillow')}. " + f"Detected installer: {_ansi('bold', installer)}." + ) + print(f" Proposed command: {_ansi('bold', ' '.join(argv))}") + + if not _installer_available(installer): + print( + f"{_ansi('red', '❌')} {installer} not found on PATH. " + f"Install it first, or run the equivalent command manually." + ) + return False + + if not auto and not _prompt_yes_no("Run it now?", default=False): + print(" Aborted. Install manually and re-run `bgo tray`.") + return False + + print(f"{_ansi('green', '▶')} Running: {' '.join(argv)}") + try: + result = subprocess.run(argv, check=False) + except (subprocess.SubprocessError, OSError) as exc: + print(f"{_ansi('red', '❌')} install failed to launch: {exc}") + return False + + if result.returncode != 0: + print( + f"{_ansi('red', '❌')} install exited with code " + f"{result.returncode}." + ) + return False + + # Verify the install actually delivered the deps. If the user is + # running in a different env than the one we just modified (uv tool + # case in particular), they need to re-exec via the installed + # entrypoint — bgo handles that in cmd_tray's re-exec path. + print(f"{_ansi('green', '✅')} install complete.") + return True + + +__all__ = ["ensure_installed", "detect_installer", "InstallerKind"] diff --git a/tests/test_tray.py b/tests/test_tray.py new file mode 100644 index 0000000..a84bb89 --- /dev/null +++ b/tests/test_tray.py @@ -0,0 +1,164 @@ +"""Tests for the pure (toolkit-free) helpers in ``bgo_cli._tray``. + +Anything that needs ``pystray`` or ``PIL`` lives in ``_run_tray`` and +is intentionally not covered here — it requires the optional extra +``bgo-cli[tray]`` and is exercised by the smoke-run in a dev install. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest import mock + +import pytest + +from bgo_cli import _tray + + +# --- load_snapshots ------------------------------------------------------ + + +def _write_proc(d: Path, name: str, **fields: object) -> None: + """Write a fake proc JSON file.""" + data = {"name": name, "status": "running", "pid": 100, **fields} + (d / f"{name}.json").write_text(json.dumps(data)) + + +def test_load_snapshots_empty_when_dir_missing(tmp_path: Path) -> None: + assert _tray.load_snapshots(tmp_path / "nope") == [] + + +def test_load_snapshots_reads_and_sorts(tmp_path: Path) -> None: + _write_proc(tmp_path, "zeta", pid=2) + _write_proc(tmp_path, "alpha", pid=1) + snaps = _tray.load_snapshots(tmp_path) + assert [s.name for s in snaps] == ["alpha", "zeta"] + assert snaps[0].pid == 1 + assert snaps[1].pid == 2 + + +def test_load_snapshots_skips_malformed(tmp_path: Path) -> None: + _write_proc(tmp_path, "good") + (tmp_path / "bad.json").write_text("{ not json") + snaps = _tray.load_snapshots(tmp_path) + assert len(snaps) == 1 + assert snaps[0].name == "good" + + +def test_load_snapshots_normalizes_missing_fields(tmp_path: Path) -> None: + (tmp_path / "x.json").write_text(json.dumps({})) + snaps = _tray.load_snapshots(tmp_path) + assert len(snaps) == 1 + assert snaps[0].name == "x" + assert snaps[0].status == "unknown" + assert snaps[0].pid is None + assert snaps[0].errored is False + + +def test_load_snapshots_detects_errored(tmp_path: Path) -> None: + _write_proc( + tmp_path, + "crash", + status="stopped", + watch={"enabled": True, "errored": True}, + ) + snaps = _tray.load_snapshots(tmp_path) + assert snaps[0].errored is True + + +# --- build_menu_spec ----------------------------------------------------- + + +def test_menu_spec_counts_online_and_stopped() -> None: + snaps = [ + _tray.ProcSnapshot("a", "running", 1, errored=False), + _tray.ProcSnapshot("b", "stopped", None, errored=False), + _tray.ProcSnapshot("c", "running", 3, errored=True), # errored != online + ] + spec = _tray.build_menu_spec(snaps) + assert spec.title == "bgo — 1 online / 2 stopped" + assert len(spec.procs) == 3 + labels = [a[0] for a in spec.actions] + assert labels == ["Resurrect all", "Refresh now", "Quit"] + + +def test_menu_spec_empty_when_no_procs() -> None: + spec = _tray.build_menu_spec([]) + assert "0 online / 0 stopped" in spec.title + assert spec.procs == [] + + +@pytest.mark.parametrize( + ("status", "errored", "expected"), + [ + ("running", False, "online"), + ("running", True, "errored"), + ("stopped", False, "stopped"), + ("anything-else", False, "stopped"), + ], +) +def test_status_label_matrix(status: str, errored: bool, expected: str) -> None: + snap = _tray.ProcSnapshot("x", status, 1, errored=errored) + assert _tray._status_label(snap) == expected + + +# --- _poll_interval ----------------------------------------------------- + + +def test_poll_interval_default(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("BGO_TRAY_POLL", raising=False) + assert _tray._poll_interval() == _tray._DEFAULT_POLL_SECONDS + + +def test_poll_interval_env_override(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("BGO_TRAY_POLL", "10") + assert _tray._poll_interval() == 10 + + +def test_poll_interval_ignores_garbage(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("BGO_TRAY_POLL", "garbage") + assert _tray._poll_interval() == _tray._DEFAULT_POLL_SECONDS + + +def test_poll_interval_ignores_zero_or_negative( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("BGO_TRAY_POLL", "0") + assert _tray._poll_interval() == _tray._DEFAULT_POLL_SECONDS + monkeypatch.setenv("BGO_TRAY_POLL", "-5") + assert _tray._poll_interval() == _tray._DEFAULT_POLL_SECONDS + + +# --- run_bgo ------------------------------------------------------------- + + +def test_run_bgo_invokes_subprocess_with_resolved_binary( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_tray.shutil, "which", lambda _: "/x/bgo") + completed = mock.MagicMock(returncode=0) + with mock.patch.object(_tray.subprocess, "run", return_value=completed) as run: + rc = _tray.run_bgo("restart", "web") + assert rc == 0 + argv = run.call_args.args[0] + assert argv == ["/x/bgo", "restart", "web"] + + +def test_run_bgo_returns_127_on_oserror(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(_tray.shutil, "which", lambda _: "/x/bgo") + with mock.patch.object(_tray.subprocess, "run", side_effect=OSError("x")): + assert _tray.run_bgo("start", "x") == 127 + + +def test_run_bgo_falls_back_to_argv0_when_which_misses( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(_tray.shutil, "which", lambda _: None) + fake = tmp_path / "bgo" + fake.touch() + monkeypatch.setattr(_tray.sys, "argv", [str(fake)]) + completed = mock.MagicMock(returncode=0) + with mock.patch.object(_tray.subprocess, "run", return_value=completed) as run: + _tray.run_bgo("ls") + assert run.call_args.args[0][0] == str(fake.resolve()) diff --git a/tests/test_tray_install.py b/tests/test_tray_install.py new file mode 100644 index 0000000..9af115a --- /dev/null +++ b/tests/test_tray_install.py @@ -0,0 +1,179 @@ +"""Tests for ``bgo_cli._tray_install``. + +Verifies installer detection from ``sys.prefix``, command construction +per installer kind, and the prompt + subprocess flow when one of +``pystray`` / ``PIL`` is missing. +""" + +from __future__ import annotations + +from unittest import mock + +import pytest + +from bgo_cli import _tray_install + + +# --- detect_installer --------------------------------------------------- + + +@pytest.mark.parametrize( + ("prefix", "expected"), + [ + ("/home/u/.local/share/uv/tools/bgo-cli", "uv"), + ("/home/u/.local/share/UV/Tools/bgo-cli", "uv"), + ("/home/u/.local/share/pipx/venvs/bgo-cli", "pipx"), + ("/usr/local", "pip"), + ("/home/u/.venv", "pip"), + ("", "pip"), + ], +) +def test_detect_installer_from_prefix( + monkeypatch: pytest.MonkeyPatch, prefix: str, expected: str +) -> None: + """The path pattern in ``sys.prefix`` selects the installer kind.""" + monkeypatch.setattr(_tray_install.sys, "prefix", prefix) + assert _tray_install.detect_installer() == expected + + +# --- _command_for -------------------------------------------------------- + + +def test_command_for_uv() -> None: + argv = _tray_install._command_for("uv") + assert argv[:3] == ["uv", "tool", "install"] + assert "pystray" in argv + assert "Pillow" in argv + assert argv[-1] == "bgo-cli" + + +def test_command_for_pipx() -> None: + assert _tray_install._command_for("pipx") == [ + "pipx", "inject", "bgo-cli", "pystray", "Pillow" + ] + + +def test_command_for_pip_uses_current_python(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(_tray_install.sys, "executable", "/python") + argv = _tray_install._command_for("pip") + assert argv[:3] == ["/python", "-m", "pip"] + assert "--user" in argv + assert "pystray" in argv + assert "Pillow" in argv + + +# --- _installer_available ----------------------------------------------- + + +def test_installer_available_uv_yes(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(_tray_install.shutil, "which", lambda _: "/usr/bin/uv") + assert _tray_install._installer_available("uv") is True + + +def test_installer_available_uv_no(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(_tray_install.shutil, "which", lambda _: None) + assert _tray_install._installer_available("uv") is False + + +def test_installer_available_pip_always_true() -> None: + assert _tray_install._installer_available("pip") is True + + +# --- ensure_installed: short-circuits if deps present ------------------- + + +def test_ensure_installed_short_circuits_when_deps_present( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If pystray + PIL import cleanly, no install command runs.""" + fake_pystray = mock.MagicMock() + fake_pil = mock.MagicMock() + monkeypatch.setitem(_tray_install.sys.modules, "pystray", fake_pystray) + monkeypatch.setitem(_tray_install.sys.modules, "PIL", fake_pil) + with mock.patch.object(_tray_install.subprocess, "run") as run: + ok = _tray_install.ensure_installed(auto=False) + assert ok is True + run.assert_not_called() + + +# --- ensure_installed: install paths ------------------------------------ + + +@pytest.fixture +def missing_deps(monkeypatch: pytest.MonkeyPatch) -> None: + """Force the ``import pystray`` line inside ``ensure_installed`` to fail.""" + # Drop any cached modules from the importer. + monkeypatch.delitem(_tray_install.sys.modules, "pystray", raising=False) + monkeypatch.delitem(_tray_install.sys.modules, "PIL", raising=False) + + real_import = __builtins__["__import__"] if isinstance( + __builtins__, dict + ) else __import__ + + def fake_import(name, *args, **kwargs): # type: ignore[no-untyped-def] + if name in ("pystray", "PIL"): + raise ImportError(f"no module named {name!r}") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr("builtins.__import__", fake_import) + + +def test_ensure_installed_runs_install_when_auto_true( + missing_deps: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """``auto=True`` skips the prompt and invokes the install argv.""" + monkeypatch.setattr(_tray_install, "detect_installer", lambda: "pip") + monkeypatch.setattr(_tray_install, "_installer_available", lambda _k: True) + completed = mock.MagicMock(returncode=0) + with mock.patch.object(_tray_install.subprocess, "run", return_value=completed) as run: + ok = _tray_install.ensure_installed(auto=True) + assert ok is True + run.assert_called_once() + + +def test_ensure_installed_aborts_without_auto_or_yes( + missing_deps: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """Without ``auto`` and without ``y``, we abort.""" + monkeypatch.setattr(_tray_install, "detect_installer", lambda: "pip") + monkeypatch.setattr(_tray_install, "_installer_available", lambda _k: True) + monkeypatch.setattr(_tray_install, "_prompt_yes_no", lambda *_a, **_k: False) + with mock.patch.object(_tray_install.subprocess, "run") as run: + ok = _tray_install.ensure_installed(auto=False) + assert ok is False + run.assert_not_called() + + +def test_ensure_installed_returns_false_when_installer_missing( + missing_deps: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """If ``uv``/``pipx`` is not on PATH, abort with a clear message.""" + monkeypatch.setattr(_tray_install, "detect_installer", lambda: "uv") + monkeypatch.setattr(_tray_install, "_installer_available", lambda _k: False) + with mock.patch.object(_tray_install.subprocess, "run") as run: + ok = _tray_install.ensure_installed(auto=True) + assert ok is False + run.assert_not_called() + + +def test_ensure_installed_propagates_subprocess_failure( + missing_deps: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """A non-zero return code from the installer surfaces as ``False``.""" + monkeypatch.setattr(_tray_install, "detect_installer", lambda: "pip") + monkeypatch.setattr(_tray_install, "_installer_available", lambda _k: True) + completed = mock.MagicMock(returncode=1) + with mock.patch.object(_tray_install.subprocess, "run", return_value=completed): + ok = _tray_install.ensure_installed(auto=True) + assert ok is False + + +def test_ensure_installed_handles_oserror( + missing_deps: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """If subprocess can't launch (OSError), we degrade gracefully.""" + monkeypatch.setattr(_tray_install, "detect_installer", lambda: "pip") + monkeypatch.setattr(_tray_install, "_installer_available", lambda _k: True) + with mock.patch.object(_tray_install.subprocess, "run", side_effect=OSError("x")): + ok = _tray_install.ensure_installed(auto=True) + assert ok is False From d40b07d950a8334981d7ce05665daf2e4561e330 Mon Sep 17 00:00:00 2001 From: tcsenpai Date: Tue, 19 May 2026 11:53:30 +0200 Subject: [PATCH 05/16] feat(cli): wire 'bgo autostart' and 'bgo tray' subcommands; document features - bgo autostart install|uninstall|status [--tray] [--json] - bgo tray [--poll N] [--auto-install] - both routed via lazy imports so missing optional deps degrade cleanly instead of failing at startup - 'autostart' and 'tray' added to known_commands so direct-mode parsing doesn't misroute them as proc names - pyproject: add [tray] optional extra (pystray>=0.19, Pillow>=10) - README: three new sections (Desktop notifications, Autostart at login, Tray icon (optional)) with env vars, install commands per installer, and Wayland caveat --- README.md | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 4 +++ 2 files changed, 82 insertions(+) diff --git a/README.md b/README.md index 89defb5..786953d 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,84 @@ When a proc enters `errored`: | `--on-fast-crash MODE` | `backoff` | One of `backoff`, `stop`, `retry` | | `--reset` | off | `bgo watch` only — reset prior watch config to defaults | +## Desktop notifications + +`bgo` fires a desktop notification when a watched process enters the +`errored` state (fast-crash budget exhausted, restart failed, etc.). +Zero new dependencies — it shells out to the platform's native +notifier: + +- Linux: `notify-send` (libnotify; install via your distro). +- macOS: built-in `osascript`. Falls back to `terminal-notifier` if + available. + +Notifications are best-effort — if no notifier is reachable, the +watcher carries on silently. + +| Env var | Values | Default | Effect | +|---|---|---|---| +| `BGO_NOTIFY` | `off`, `errors`, `all` | `errors` | What to fire on | +| `BGO_NOTIFY_CMD` | argv template with `{title}` / `{body}` | — | Override the backend entirely | + +## Autostart at login + +`bgo autostart` installs a per-user service that runs `bgo resurrect` +on session start, restoring every process that was registered as +`running` at shutdown. Backends are auto-detected: + +- Linux → systemd user unit at `~/.config/systemd/user/bgo-resurrect.service` +- macOS → LaunchAgent at `~/Library/LaunchAgents/sh.discus.bgo.resurrect.plist` + +```bash +bgo autostart install # install resurrect at login +bgo autostart install --tray # also install the tray icon at login +bgo autostart status # show what's installed +bgo autostart status --json # machine-readable +bgo autostart uninstall # remove +bgo autostart uninstall --tray # remove tray entry only +``` + +On Linux, services only run after the user logs in. To start the +resurrect unit before a graphical session exists (e.g. headless +servers), enable lingering once: `loginctl enable-linger $USER`. +Neither flag is set automatically — it modifies system state. + +## Tray icon (optional) + +`bgo tray` runs a system-tray icon that lists all registered procs +and exposes Start / Stop / Restart / Open-logs actions for each. The +menu refreshes from `~/.bgo/procs/*.json` every few seconds and +shells out to `bgo` for every action, so behavior matches the CLI +exactly. + +Tray UI needs `pystray` and `Pillow`. They're shipped as an optional +extra to keep the core install zero-dep: + +```bash +# uv (recommended) +uv tool install bgo-cli --with pystray --with Pillow + +# pipx +pipx install bgo-cli +pipx inject bgo-cli pystray Pillow + +# pip +pip install 'bgo-cli[tray]' +``` + +If you forget, the first run of `bgo tray` detects your installer +(uv tool / pipx / pip) and offers to inject the deps for you. Skip +the confirmation with `--auto-install` or `BGO_TRAY_AUTOINSTALL=1`. + +| Flag / env | Default | Effect | +|---|---|---| +| `--poll N`, `BGO_TRAY_POLL` | `3` | Snapshot refresh interval (seconds) | +| `--auto-install`, `BGO_TRAY_AUTOINSTALL=1` | off | Skip the install prompt | + +Wayland support depends on your compositor — GNOME needs the +"AppIndicator" extension; KDE and most wlroots compositors work out +of the box. + ## Storage - State: `~/.bgo/procs/.json` — one file per process, written atomically (tmp + `os.replace`) diff --git a/pyproject.toml b/pyproject.toml index 4f2e6d3..89de2d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,10 @@ dev = [ "build>=1.0", "twine>=4.0", ] +tray = [ + "pystray>=0.19", + "Pillow>=10", +] [project.scripts] bgo = "bgo_cli:main" From a7979b96bced6fe160e56f4cd87089beaaae4333 Mon Sep 17 00:00:00 2001 From: tcsenpai Date: Tue, 19 May 2026 11:58:52 +0200 Subject: [PATCH 06/16] fix: caveman-review autofix pass - notify: parse BGO_NOTIFY_CMD with shlex.split so quoted args group correctly (was: raw .split() lost quoting); malformed quotes now fall through to platform detection instead of producing an unrunnable argv. - autostart: harden _resolve_bgo_binary to abspath via Path.resolve() even when shutil.which returns a relative entry; _launchctl_unload now actually surfaces failures instead of unconditionally returning True (still idempotent on 'Could not find specified service'). - tray: 'Start' menu entry was broken because 'bgo start ' without a command is rejected by the CLI. Route both Start (for stopped) and Restart (for online) through 'bgo restart ', which respawns with the stored command. _open_logs now reports fall-through failures on stderr instead of silently no-op'ing. - bgo: cmd_tray re-exec resolves the installed entrypoint via shutil.which('bgo') instead of sys.argv[0], which may be the bare script path or 'python' after a uv/pipx injection. - tray_install: honor $UV_TOOL_DIR for non-default uv tool roots; document the re-exec contract on ensure_installed. - tests: +6 covering shlex quoting, malformed override fallthrough, launchctl unload fallback + missing-agent idempotency + real failure, and UV_TOOL_DIR detection. 144 -> 150 tests. --- bgo | 11 +++++++--- src/bgo_cli/_autostart.py | 35 +++++++++++++++++++++++++++----- src/bgo_cli/_notify.py | 14 ++++++++++--- src/bgo_cli/_tray.py | 31 ++++++++++++++++++++-------- src/bgo_cli/_tray_install.py | 20 ++++++++++++++++++ tests/test_autostart.py | 39 ++++++++++++++++++++++++++++++++++++ tests/test_notify.py | 21 +++++++++++++++++++ tests/test_tray_install.py | 12 +++++++++++ 8 files changed, 164 insertions(+), 19 deletions(-) diff --git a/bgo b/bgo index f7fe076..474460d 100755 --- a/bgo +++ b/bgo @@ -1804,9 +1804,14 @@ def cmd_tray(args): ) if not ok: return 1 - # Re-exec self with the same args so the freshly-installed - # deps are picked up by a clean interpreter. - os.execvp(sys.argv[0], sys.argv) + # Re-exec via the installed entrypoint so the freshly-injected + # deps are picked up by a clean interpreter. We prefer + # shutil.which("bgo") over sys.argv[0] because argv[0] may be + # the bare script path ("./bgo") or "python", neither of which + # have the new pystray on their import path after a uv/pipx + # injection completed. + bgo_bin = shutil.which("bgo") or sys.argv[0] + os.execvp(bgo_bin, [bgo_bin, "tray"]) return 0 # unreachable return _tray.run(poll_seconds=getattr(args, "poll", None)) diff --git a/src/bgo_cli/_autostart.py b/src/bgo_cli/_autostart.py index 1e8cafe..eb86400 100644 --- a/src/bgo_cli/_autostart.py +++ b/src/bgo_cli/_autostart.py @@ -131,7 +131,10 @@ def _resolve_bgo_binary() -> str: """ found = shutil.which("bgo") if found: - return found + # ``which`` already returns absolute paths on all supported + # platforms, but harden against PATH entries that contain + # ``..`` or are themselves relative (rare but legal). + return str(Path(found).resolve()) # Fall back to the script that invoked us. resolve() collapses # symlinks; we want the absolute, canonical path. return str(Path(sys.argv[0]).resolve()) @@ -235,11 +238,33 @@ def _launchctl_load(plist: Path) -> tuple[bool, str]: def _launchctl_unload(plist: Path) -> tuple[bool, str]: - """Tear down a per-user agent. Missing agents are not an error.""" + """Tear down a per-user agent. Missing agents are not an error. + + Tries the modern ``bootout`` first, then falls back to the legacy + ``unload -w`` form. We accept either success because users on + older macOS won't have ``bootout``. A failure from *both* is + surfaced so callers don't silently leave a registered agent + behind after the plist file is removed. + """ uid = os.getuid() - _run(["launchctl", "bootout", f"gui/{uid}", str(plist)]) - _run(["launchctl", "unload", "-w", str(plist)]) - return True, "" + rc_b, out_b = _run(["launchctl", "bootout", f"gui/{uid}", str(plist)]) + if rc_b == 0: + return True, "" + rc_u, out_u = _run(["launchctl", "unload", "-w", str(plist)]) + if rc_u == 0: + return True, "" + # macOS reports "Could not find specified service" when the agent + # is already absent — treat that as success so uninstall stays + # idempotent. + combined = (out_b + out_u).lower() + for phrase in ( + "could not find", + "no such file", + "service is not loaded", + ): + if phrase in combined: + return True, "" + return False, f"bootout: {out_b.strip()}; unload: {out_u.strip()}" # --- Public API ---------------------------------------------------------- diff --git a/src/bgo_cli/_notify.py b/src/bgo_cli/_notify.py index 6b52e29..8c876be 100644 --- a/src/bgo_cli/_notify.py +++ b/src/bgo_cli/_notify.py @@ -25,6 +25,7 @@ from __future__ import annotations import os +import shlex import shutil import subprocess import sys @@ -83,9 +84,16 @@ def _resolve_backend() -> tuple[str, Sequence[str]] | None: """ override = (os.environ.get("BGO_NOTIFY_CMD") or "").strip() if override: - # Split lazily; user is responsible for sane quoting. We do not - # invoke a shell, so injection is bounded to argv only. - return "override", tuple(override.split()) + # shlex.split respects shell-style quoting so the user can + # group arguments containing spaces. We do not invoke a shell + # at runtime, so injection is bounded to argv only. + try: + parts = shlex.split(override) + except ValueError: + # Unbalanced quotes etc. — fall through to platform detection. + parts = [] + if parts: + return "override", tuple(parts) if sys.platform.startswith("linux"): if shutil.which("notify-send"): diff --git a/src/bgo_cli/_tray.py b/src/bgo_cli/_tray.py index 1a9446d..9bdb859 100644 --- a/src/bgo_cli/_tray.py +++ b/src/bgo_cli/_tray.py @@ -166,22 +166,29 @@ def run_bgo(*args: str) -> int: def _open_logs(name: str) -> None: - """Open the proc's stdout log in ``$EDITOR`` or the OS default.""" + """Open the proc's stdout log in ``$EDITOR`` or the OS default. + + Failures are reported to stderr so the user has *some* signal when + both the editor and the OS default opener fail (e.g. headless + server with no xdg-open). They do not propagate — the tray loop + must stay alive. + """ log = BGO_DIR / "logs" / f"{name}.out.log" if not log.exists(): + sys.stderr.write(f"bgo tray: no log file for {name}\n") return editor = os.environ.get("EDITOR") if editor: try: subprocess.Popen([editor, str(log)]) return - except OSError: - pass + except OSError as exc: + sys.stderr.write(f"bgo tray: $EDITOR ({editor}) failed: {exc}\n") opener = "open" if sys.platform == "darwin" else "xdg-open" try: subprocess.Popen([opener, str(log)]) - except OSError: - pass + except OSError as exc: + sys.stderr.write(f"bgo tray: {opener} failed: {exc}\n") def _poll_interval() -> int: @@ -225,10 +232,18 @@ def _run_tray(poll_seconds: int) -> int: # pragma: no cover — needs pystray quit_flag = {"stop": False} def make_proc_submenu(snap: ProcSnapshot) -> Menu: + # ``bgo start `` without a command argument is rejected + # by the core CLI (start requires REMAINDER). For a known + # registered proc the correct re-spawn is ``bgo restart``, + # which preserves the stored command. We surface "Restart" + # for online procs and "Start" (which shells out to restart + # under the hood) for stopped ones — both routes hit the + # same code path, but the labels match user expectations. + is_running = snap.status == "running" and not snap.errored + primary_label = "Restart" if is_running else "Start" return Menu( - Item("Start", lambda _icon, _it, n=snap.name: run_bgo("start", n)), - Item("Stop", lambda _icon, _it, n=snap.name: run_bgo("stop", n)), - Item("Restart", lambda _icon, _it, n=snap.name: run_bgo("restart", n)), + Item(primary_label, lambda _icon, _it, n=snap.name: run_bgo("restart", n)), + Item("Stop", lambda _icon, _it, n=snap.name: run_bgo("stop", n)), Menu.SEPARATOR, Item("Open logs", lambda _icon, _it, n=snap.name: _open_logs(n)), ) diff --git a/src/bgo_cli/_tray_install.py b/src/bgo_cli/_tray_install.py index 1e5bf69..bf4a2dd 100644 --- a/src/bgo_cli/_tray_install.py +++ b/src/bgo_cli/_tray_install.py @@ -47,9 +47,18 @@ def detect_installer() -> InstallerKind: Uses ``sys.prefix`` because every installer roots bgo's venv inside its own per-tool tree. We check the most specific markers first (``uv tool``, ``pipx``) and fall through to plain ``pip``. + + The ``UV_TOOL_DIR`` env var is honored when set, so users who + have moved uv's tool root outside the default ``~/.local/share`` + location are still detected. """ prefix = (sys.prefix or "").replace("\\", "/") lower = prefix.lower() + uv_tool_dir = (os.environ.get("UV_TOOL_DIR") or "").strip() + if uv_tool_dir: + normalized = uv_tool_dir.replace("\\", "/").rstrip("/").lower() + if normalized and normalized in lower: + return "uv" if "/uv/tools/" in lower or "uv-tool" in lower: return "uv" if "/pipx/venvs/" in lower or "/pipx/" in lower: @@ -103,6 +112,17 @@ def ensure_installed(auto: bool = False) -> bool: :param auto: Skip the y/N prompt and run the install command. :returns: ``True`` once both deps import cleanly. ``False`` if the user declined or the install command failed. + + .. important:: + For ``uv tool`` and ``pipx`` install contexts, a successful + return value indicates the dependencies were installed into the + *target* environment, **not** the current Python process. + The caller is responsible for re-execing the appropriate ``bgo`` + binary (``shutil.which("bgo")``) so the new interpreter picks + up the freshly injected deps. ``cmd_tray`` in the root ``bgo`` + script implements this re-exec. The plain ``pip`` path installs + into the current Python's user site and the caller can usually + continue in-process — but re-exec is still the safe default. """ try: import pystray # noqa: F401 (presence check only) diff --git a/tests/test_autostart.py b/tests/test_autostart.py index cbaa6ef..546a106 100644 --- a/tests/test_autostart.py +++ b/tests/test_autostart.py @@ -239,6 +239,45 @@ def test_uninstall_darwin_removes_plist(darwin_env: Path) -> None: assert not plist.exists() +def test_launchctl_unload_falls_back_to_legacy(tmp_path: Path) -> None: + """``bootout`` failing triggers a ``unload -w`` retry.""" + calls: list[list[str]] = [] + + def fake_run(argv: list[str]) -> tuple[int, str]: + calls.append(argv) + if argv[1] == "bootout": + return 1, "bootout: not supported" + return 0, "" # unload succeeds + + with mock.patch.object(_autostart, "_run", side_effect=fake_run): + ok, _ = _autostart._launchctl_unload(tmp_path / "x.plist") + assert ok is True + assert calls[0][1] == "bootout" + assert calls[1][1] == "unload" + + +def test_launchctl_unload_treats_missing_agent_as_success(tmp_path: Path) -> None: + """``Could not find specified service`` is benign — idempotent uninstall.""" + def fake_run(argv: list[str]) -> tuple[int, str]: + return 1, "Could not find specified service" + + with mock.patch.object(_autostart, "_run", side_effect=fake_run): + ok, msg = _autostart._launchctl_unload(tmp_path / "x.plist") + assert ok is True + assert msg == "" + + +def test_launchctl_unload_surfaces_real_failure(tmp_path: Path) -> None: + """Both bootout and unload failing with unknown errors -> ok=False.""" + def fake_run(argv: list[str]) -> tuple[int, str]: + return 1, "permission denied" + + with mock.patch.object(_autostart, "_run", side_effect=fake_run): + ok, msg = _autostart._launchctl_unload(tmp_path / "x.plist") + assert ok is False + assert "permission denied" in msg + + # --- status -------------------------------------------------------------- diff --git a/tests/test_notify.py b/tests/test_notify.py index 168de5f..84c5773 100644 --- a/tests/test_notify.py +++ b/tests/test_notify.py @@ -54,6 +54,27 @@ def test_resolve_backend_override_wins() -> None: assert argv == ("my-notifier", "{title}", "{body}") +def test_resolve_backend_override_respects_quotes() -> None: + """Quoted args in ``$BGO_NOTIFY_CMD`` stay grouped.""" + env = {"BGO_NOTIFY_CMD": '"/opt/bin/notify ng" --json {title}'} + with mock.patch.dict(_notify.os.environ, env, clear=False): + result = _notify._resolve_backend() + assert result is not None + kind, argv = result + assert kind == "override" + assert argv == ("/opt/bin/notify ng", "--json", "{title}") + + +def test_resolve_backend_override_with_unbalanced_quotes_falls_through( + monkeypatch: "pytest.MonkeyPatch", +) -> None: + """Malformed override is ignored; platform detection still runs.""" + monkeypatch.setenv("BGO_NOTIFY_CMD", 'broken "quote') + monkeypatch.setattr(_notify.sys, "platform", "linux") + monkeypatch.setattr(_notify.shutil, "which", lambda _name: None) + assert _notify._resolve_backend() is None + + def test_resolve_backend_linux_notify_send() -> None: """On Linux, ``notify-send`` is picked when present.""" with mock.patch.dict(_notify.os.environ, {}, clear=True), \ diff --git a/tests/test_tray_install.py b/tests/test_tray_install.py index 9af115a..e9c5021 100644 --- a/tests/test_tray_install.py +++ b/tests/test_tray_install.py @@ -33,9 +33,21 @@ def test_detect_installer_from_prefix( ) -> None: """The path pattern in ``sys.prefix`` selects the installer kind.""" monkeypatch.setattr(_tray_install.sys, "prefix", prefix) + monkeypatch.delenv("UV_TOOL_DIR", raising=False) assert _tray_install.detect_installer() == expected +def test_detect_installer_honors_uv_tool_dir_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A custom ``UV_TOOL_DIR`` location is detected as uv.""" + monkeypatch.setenv("UV_TOOL_DIR", "/opt/custom/uv-tools") + monkeypatch.setattr( + _tray_install.sys, "prefix", "/opt/custom/uv-tools/bgo-cli" + ) + assert _tray_install.detect_installer() == "uv" + + # --- _command_for -------------------------------------------------------- From e6c5195a8d7cc585381683854cf0e0c09a32c384 Mon Sep 17 00:00:00 2001 From: tcsenpai Date: Tue, 19 May 2026 12:17:35 +0200 Subject: [PATCH 07/16] fix(tray): replace default-kwarg lambdas with closure factories pystray.MenuItem._assert_action introspects the action's signature and raises ValueError on any callable whose argspec has more than the (icon, item) positional pair. Our previous form lambda _icon, _it, n=snap.name: run_bgo('restart', n) binds the loop variable via a default-kwarg, which trips that inspector even though the call site only passes two args. Replace every menu-action lambda with a named closure factory (bgo_action, open_logs_action, refresh_action, quit_action). Each returns a fresh 2-arg callable that pystray accepts. Reproduced as 'ValueError: ' on first run of 'bgo tray' under uv tool install; now starts cleanly. --- src/bgo_cli/_tray.py | 52 ++++++++++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/src/bgo_cli/_tray.py b/src/bgo_cli/_tray.py index 9bdb859..ea03649 100644 --- a/src/bgo_cli/_tray.py +++ b/src/bgo_cli/_tray.py @@ -231,21 +231,50 @@ def _run_tray(poll_seconds: int) -> int: # pragma: no cover — needs pystray quit_flag = {"stop": False} + # pystray's MenuItem inspects the action's argspec and rejects + # callables with more than the (icon, item) positional pair. Using + # ``lambda _icon, _it, n=name: ...`` to bind loop variables fools + # the introspector, so we use proper closure factories instead. + def bgo_action(*args: str): + """Return a 2-arg callable that runs ``bgo ``.""" + def _action(_icon, _item): # noqa: ANN001 — pystray contract + run_bgo(*args) + return _action + + def open_logs_action(name: str): + """Return a 2-arg callable that opens ``name``'s log.""" + def _action(_icon, _item): # noqa: ANN001 + _open_logs(name) + return _action + + def refresh_action(): + """Return a 2-arg callable that rebuilds the menu in place.""" + def _action(icon, _item): # noqa: ANN001 + icon.menu = build_menu() + icon.update_menu() + return _action + + def quit_action(): + """Return a 2-arg callable that signals the poller and stops.""" + def _action(icon, _item): # noqa: ANN001 + quit_flag["stop"] = True + icon.stop() + return _action + def make_proc_submenu(snap: ProcSnapshot) -> Menu: # ``bgo start `` without a command argument is rejected # by the core CLI (start requires REMAINDER). For a known # registered proc the correct re-spawn is ``bgo restart``, # which preserves the stored command. We surface "Restart" - # for online procs and "Start" (which shells out to restart - # under the hood) for stopped ones — both routes hit the - # same code path, but the labels match user expectations. + # for online procs and "Start" for stopped ones — both labels + # route to the same ``bgo restart`` underneath. is_running = snap.status == "running" and not snap.errored primary_label = "Restart" if is_running else "Start" return Menu( - Item(primary_label, lambda _icon, _it, n=snap.name: run_bgo("restart", n)), - Item("Stop", lambda _icon, _it, n=snap.name: run_bgo("stop", n)), + Item(primary_label, bgo_action("restart", snap.name)), + Item("Stop", bgo_action("stop", snap.name)), Menu.SEPARATOR, - Item("Open logs", lambda _icon, _it, n=snap.name: _open_logs(n)), + Item("Open logs", open_logs_action(snap.name)), ) def build_menu() -> Menu: @@ -257,16 +286,11 @@ def build_menu() -> Menu: items.append(Menu.SEPARATOR) for label, cmd in spec.actions: if cmd == "__quit__": - def _quit(icon, _item): # noqa: ANN001 - quit_flag["stop"] = True - icon.stop() - items.append(Item(label, _quit)) + items.append(Item(label, quit_action())) elif cmd == "__refresh__": - items.append(Item(label, lambda icon, _it: icon.update_menu())) + items.append(Item(label, refresh_action())) else: - items.append( - Item(label, lambda _icon, _it, c=cmd: run_bgo(c)) - ) + items.append(Item(label, bgo_action(cmd))) return Menu(*items) icon = pystray.Icon( From 2315c370a024622e0b5425623262c7ca43b7d8c7 Mon Sep 17 00:00:00 2001 From: tcsenpai Date: Tue, 19 May 2026 12:27:30 +0200 Subject: [PATCH 08/16] feat(tray): replace pystray with PySide6 for universal Wayland+macOS support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pystray's default Xorg backend cannot dock under Wayland (it asserts on _systray_manager being non-None), and its AppIndicator backend needs distro typelibs plus a GNOME extension. Switching to PySide6 gives us a single library that speaks StatusNotifierItem natively on Linux (KDE Plasma 6, Hyprland+waybar, sway, etc.) and uses native NSStatusItem on macOS via Qt's Cocoa integration. Changes: - _tray.py rewritten: build_menu_spec / load_snapshots / ProcSnapshot / run_bgo are unchanged (still toolkit-agnostic, still tested). _run_tray now uses QApplication + QSystemTrayIcon + QMenu + QTimer. Icon is an embedded SVG rendered via QSvgRenderer at QIcon time, so we ship no binary assets. QTimer keeps polling on the GUI thread so there's no thread-unsafe menu mutation. We hold strong refs to every QAction/QMenu we create because Qt doesn't take ownership. - Detection: if QSystemTrayIcon.isSystemTrayAvailable() returns False (typical on GNOME without the AppIndicator extension or on headless sessions), we print a per-distro install hint and exit 1 instead of crashing. - _tray_install: dep list trimmed from (pystray, Pillow) to (PySide6). All three installer paths (uv tool / pipx / pip) rewritten accordingly. - pyproject [tray] extra is now just 'PySide6>=6.6'. - README: rewritten tray section with platform support matrix and an explicit GNOME prerequisite section. - Removed _select_pystray_backend / _print_appindicator_hint / _make_icon_image. Their tests are dropped — Qt handles backend selection internally and the SVG icon needs no Pillow pass. - bgo (root script): updated docstrings / help text to mention PySide6 instead of pystray/Pillow. GNOME Wayland still needs the AppIndicator shell extension — that's a GNOME limitation, no Python library can paper over it. Documented in README. Live-verified on Fedora 44 Wayland: 'bgo tray' starts cleanly, Qt event loop runs without error, no docking assertion. Test count unchanged at 150 (6 pystray-backend-selection tests removed; 6 Qt-glue paths are pragma: no cover by design). --- README.md | 40 +++-- bgo | 6 +- pyproject.toml | 3 +- src/bgo_cli/_tray.py | 277 +++++++++++++++++++++-------------- src/bgo_cli/_tray_install.py | 36 ++--- tests/test_tray.py | 6 +- tests/test_tray_install.py | 30 ++-- 7 files changed, 241 insertions(+), 157 deletions(-) diff --git a/README.md b/README.md index 786953d..1e6a5f6 100644 --- a/README.md +++ b/README.md @@ -297,34 +297,56 @@ menu refreshes from `~/.bgo/procs/*.json` every few seconds and shells out to `bgo` for every action, so behavior matches the CLI exactly. -Tray UI needs `pystray` and `Pillow`. They're shipped as an optional -extra to keep the core install zero-dep: +Tray UI uses **PySide6** (Qt for Python, LGPL). One library, native +support on every platform we target: + +| Platform | What happens | +|---|---| +| **macOS** | Native `NSStatusItem` in the menu bar | +| **KDE Plasma 6 (Wayland or X11)** | StatusNotifierItem, no setup | +| **Hyprland / sway + waybar** | SNI via waybar's `tray` module | +| **GNOME Wayland** | Needs the AppIndicator shell extension (see below) | + +PySide6 is shipped as an optional extra to keep the core install +zero-dep: ```bash # uv (recommended) -uv tool install bgo-cli --with pystray --with Pillow +uv tool install bgo-cli --with PySide6 # pipx pipx install bgo-cli -pipx inject bgo-cli pystray Pillow +pipx inject bgo-cli PySide6 # pip pip install 'bgo-cli[tray]' ``` If you forget, the first run of `bgo tray` detects your installer -(uv tool / pipx / pip) and offers to inject the deps for you. Skip +(uv tool / pipx / pip) and offers to inject the dep for you. Skip the confirmation with `--auto-install` or `BGO_TRAY_AUTOINSTALL=1`. +### GNOME Wayland prerequisite + +GNOME ships no system-tray host of its own. Install the +`AppIndicator and KStatusNotifierItem Support` shell extension once: + +```bash +sudo dnf install gnome-shell-extension-appindicator # Fedora +sudo apt install gnome-shell-extension-appindicator # Debian/Ubuntu +gnome-extensions enable appindicatorsupport@rgcjonas.gmail.com +``` + +Log out and back in. KDE / Hyprland / macOS users do not need this +step. + +### Tuning + | Flag / env | Default | Effect | |---|---|---| | `--poll N`, `BGO_TRAY_POLL` | `3` | Snapshot refresh interval (seconds) | | `--auto-install`, `BGO_TRAY_AUTOINSTALL=1` | off | Skip the install prompt | -Wayland support depends on your compositor — GNOME needs the -"AppIndicator" extension; KDE and most wlroots compositors work out -of the box. - ## Storage - State: `~/.bgo/procs/.json` — one file per process, written atomically (tmp + `os.replace`) diff --git a/bgo b/bgo index 474460d..cf832dc 100755 --- a/bgo +++ b/bgo @@ -1786,7 +1786,7 @@ def cmd_autostart(args): def cmd_tray(args): """Launch the system-tray icon (optional extra ``bgo-cli[tray]``). - If pystray/Pillow are not importable, delegates to + If PySide6 is not importable, delegates to ``_tray_install.ensure_installed`` which detects the install context (uv tool / pipx / pip) and offers to inject the deps. """ @@ -1808,7 +1808,7 @@ def cmd_tray(args): # deps are picked up by a clean interpreter. We prefer # shutil.which("bgo") over sys.argv[0] because argv[0] may be # the bare script path ("./bgo") or "python", neither of which - # have the new pystray on their import path after a uv/pipx + # have the new PySide6 on their import path after a uv/pipx # injection completed. bgo_bin = shutil.which("bgo") or sys.argv[0] os.execvp(bgo_bin, [bgo_bin, "tray"]) @@ -2150,7 +2150,7 @@ Watch mode (auto-restart on crash): ) tray_parser.add_argument( "--auto-install", dest="auto_install", action="store_true", - help="Install pystray/Pillow without confirmation if missing", + help="Install PySide6 without confirmation if missing", ) args = parser.parse_args() diff --git a/pyproject.toml b/pyproject.toml index 89de2d4..fb7f042 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,8 +47,7 @@ dev = [ "twine>=4.0", ] tray = [ - "pystray>=0.19", - "Pillow>=10", + "PySide6>=6.6", ] [project.scripts] diff --git a/src/bgo_cli/_tray.py b/src/bgo_cli/_tray.py index ea03649..7f53fb4 100644 --- a/src/bgo_cli/_tray.py +++ b/src/bgo_cli/_tray.py @@ -1,21 +1,29 @@ """System-tray icon for bgo (optional extra ``bgo-cli[tray]``). This module is imported lazily by ``cmd_tray`` and is the only place -``pystray`` / ``Pillow`` are referenced at runtime. Splitting the -import-bearing glue (``_run_tray``) from the pure menu construction -(``build_menu_spec``) lets us unit-test the menu without the optional -deps installed. +PySide6 (Qt) is referenced at runtime. Splitting the import-bearing +glue (``_run_tray``) from the pure menu construction +(``build_menu_spec``) lets us unit-test the menu without Qt installed. + +Why PySide6 +=========== +We tried ``pystray`` first. Its default Xorg backend cannot dock under +Wayland (no XEMBED system-tray manager), and its AppIndicator backend +needs distro-specific GI typelibs plus a GNOME shell extension. Qt's +``QSystemTrayIcon`` speaks the StatusNotifierItem (SNI) protocol +natively, so it works on **KDE Plasma 6**, **Hyprland + waybar**, and +other SNI-capable bars without extra setup, and falls back to native +``NSStatusItem`` on **macOS**. GNOME Wayland still requires the +``AppIndicator and KStatusNotifierItem Support`` shell extension, but +that's a GNOME limitation; no Python library can paper over it. UI model ======== We poll ``~/.bgo/procs/*.json`` every ``poll_seconds`` (default 3, -overridable by ``$BGO_TRAY_POLL`` or ``--poll``). The full menu is -rebuilt from the snapshot — pystray rebuilds cheaply, and a -declarative menu avoids the bugs that come from in-place mutation. - -Actions never duplicate state-management logic from the core script. -Every Start/Stop/Restart click shells out to the ``bgo`` binary, so -behavior matches the CLI exactly and we don't risk diverging. +overridable by ``$BGO_TRAY_POLL`` or ``--poll``) on a Qt ``QTimer`` so +all menu rebuilds happen on the GUI thread. Actions never duplicate +state-management logic from the core script — every click shells out +to the ``bgo`` binary, so behavior matches the CLI exactly. """ from __future__ import annotations @@ -203,122 +211,171 @@ def _poll_interval() -> int: return _DEFAULT_POLL_SECONDS -# --- pystray glue -------------------------------------------------------- +# --- PySide6 glue -------------------------------------------------------- + + +# SVG icon embedded as a string so we don't ship binary assets. Rendered +# in monochrome white-on-transparent, sized 64×64 so Qt downscales +# cleanly to 16/22/24 px tray slots. +_ICON_SVG = b"""\ + + + + + + +""" -def _make_icon_image(): # pragma: no cover — exercised only with Pillow - """Build a tiny B/W icon image. Imported lazily so tests don't need PIL.""" - from PIL import Image, ImageDraw +def _print_gnome_extension_hint() -> None: + """Hint shown when QSystemTrayIcon.isSystemTrayAvailable is False. - size = 64 - img = Image.new("RGB", (size, size), "black") - draw = ImageDraw.Draw(img) - # Three horizontal bars — readable at 16px. - for y in (14, 28, 42): - draw.rectangle((10, y, size - 10, y + 8), fill="white") - return img + The most common culprit on Linux is GNOME without the AppIndicator + extension. On macOS this path is essentially unreachable. On other + Wayland desktops (KDE / Hyprland / sway+waybar) the SNI host is + typically already running, so a False reading usually means the + user is in a non-graphical session (sshd, ttyN). + """ + sys.stderr.write( + "\nbgo tray: no system tray is currently available.\n" + " GNOME Wayland users need the AppIndicator shell extension:\n" + " sudo dnf install gnome-shell-extension-appindicator # Fedora\n" + " sudo apt install gnome-shell-extension-appindicator # Debian\n" + " gnome-extensions enable appindicatorsupport@rgcjonas.gmail.com\n" + " KDE Plasma, Hyprland + waybar, and macOS should work out of the\n" + " box — if they don't, make sure you're running in a graphical\n" + " session (not over plain SSH).\n" + ) -def _run_tray(poll_seconds: int) -> int: # pragma: no cover — needs pystray - """Run the pystray event loop until the user quits. +def _run_tray(poll_seconds: int) -> int: # pragma: no cover — needs Qt + """Run the Qt event loop until the user quits. - This is the only function in the module that requires the optional - deps. Kept thin: the bulk of behavior lives in the pure helpers - above so it stays testable. + This function is the only place PySide6 is touched. Everything + else in the module is framework-agnostic and unit-tested without + Qt installed. """ - import pystray - from pystray import MenuItem as Item, Menu - - quit_flag = {"stop": False} - - # pystray's MenuItem inspects the action's argspec and rejects - # callables with more than the (icon, item) positional pair. Using - # ``lambda _icon, _it, n=name: ...`` to bind loop variables fools - # the introspector, so we use proper closure factories instead. - def bgo_action(*args: str): - """Return a 2-arg callable that runs ``bgo ``.""" - def _action(_icon, _item): # noqa: ANN001 — pystray contract - run_bgo(*args) - return _action - - def open_logs_action(name: str): - """Return a 2-arg callable that opens ``name``'s log.""" - def _action(_icon, _item): # noqa: ANN001 - _open_logs(name) - return _action - - def refresh_action(): - """Return a 2-arg callable that rebuilds the menu in place.""" - def _action(icon, _item): # noqa: ANN001 - icon.menu = build_menu() - icon.update_menu() - return _action - - def quit_action(): - """Return a 2-arg callable that signals the poller and stops.""" - def _action(icon, _item): # noqa: ANN001 - quit_flag["stop"] = True - icon.stop() - return _action - - def make_proc_submenu(snap: ProcSnapshot) -> Menu: - # ``bgo start `` without a command argument is rejected - # by the core CLI (start requires REMAINDER). For a known - # registered proc the correct re-spawn is ``bgo restart``, - # which preserves the stored command. We surface "Restart" - # for online procs and "Start" for stopped ones — both labels - # route to the same ``bgo restart`` underneath. + try: + from PySide6.QtCore import QByteArray, QTimer + from PySide6.QtGui import QAction, QIcon, QPixmap + from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon + from PySide6.QtSvg import QSvgRenderer + from PySide6.QtCore import QSize, Qt + from PySide6.QtGui import QPainter + except ImportError as exc: + sys.stderr.write( + f"bgo tray: PySide6 not available: {exc}\n" + " Install the tray extra with one of:\n" + " uv tool install bgo-cli --with PySide6\n" + " pipx inject bgo-cli PySide6\n" + " pip install 'bgo-cli[tray]'\n" + ) + return 1 + + app = QApplication.instance() or QApplication(sys.argv) + app.setQuitOnLastWindowClosed(False) # tray-only apps have no window + + if not QSystemTrayIcon.isSystemTrayAvailable(): + _print_gnome_extension_hint() + return 1 + + # Render the embedded SVG into a QIcon at a useful base size. Qt + # picks the closest match for the tray slot at draw time. + renderer = QSvgRenderer(QByteArray(_ICON_SVG)) + pixmap = QPixmap(QSize(64, 64)) + pixmap.fill(Qt.transparent) + painter = QPainter(pixmap) + try: + renderer.render(painter) + finally: + painter.end() + icon_image = QIcon(pixmap) + + tray = QSystemTrayIcon(icon_image) + tray.setToolTip("bgo") + + # We have to hold a reference to QMenu and every QAction (Qt + # doesn't take ownership when added to a tray). Keeping them on a + # closure-captured list survives garbage collection of the locals. + held: list[object] = [] + + def make_proc_submenu(snap: ProcSnapshot, parent: QMenu) -> QMenu: + # ``bgo start `` without a command is rejected by the + # core CLI (start requires REMAINDER). For a registered proc + # the correct respawn is ``bgo restart``, which preserves the + # stored command. We label it Restart for online procs and + # Start for stopped ones — same code path under the hood. + sub = QMenu(snap.name, parent) is_running = snap.status == "running" and not snap.errored primary_label = "Restart" if is_running else "Start" - return Menu( - Item(primary_label, bgo_action("restart", snap.name)), - Item("Stop", bgo_action("stop", snap.name)), - Menu.SEPARATOR, - Item("Open logs", open_logs_action(snap.name)), - ) - def build_menu() -> Menu: + primary = QAction(primary_label, sub) + primary.triggered.connect(lambda *_: run_bgo("restart", snap.name)) + sub.addAction(primary) + + stop = QAction("Stop", sub) + stop.triggered.connect(lambda *_: run_bgo("stop", snap.name)) + sub.addAction(stop) + + sub.addSeparator() + + logs = QAction("Open logs", sub) + logs.triggered.connect(lambda *_: _open_logs(snap.name)) + sub.addAction(logs) + + held.extend([primary, stop, logs, sub]) + return sub + + def build_menu() -> QMenu: spec = build_menu_spec(load_snapshots()) - items: list = [Item(spec.title, None, enabled=False), Menu.SEPARATOR] + menu = QMenu() + title_action = QAction(spec.title, menu) + title_action.setEnabled(False) + menu.addAction(title_action) + menu.addSeparator() + held.append(title_action) + for snap in spec.procs: label = f"{snap.name} [{_status_label(snap)}]" - items.append(Item(label, make_proc_submenu(snap))) - items.append(Menu.SEPARATOR) + sub = make_proc_submenu(snap, menu) + sub.setTitle(label) + menu.addMenu(sub) + + menu.addSeparator() for label, cmd in spec.actions: + act = QAction(label, menu) if cmd == "__quit__": - items.append(Item(label, quit_action())) + act.triggered.connect(app.quit) elif cmd == "__refresh__": - items.append(Item(label, refresh_action())) + act.triggered.connect(lambda *_: rebuild()) else: - items.append(Item(label, bgo_action(cmd))) - return Menu(*items) - - icon = pystray.Icon( - "bgo", - icon=_make_icon_image(), - title="bgo", - menu=build_menu(), - ) - - # Periodically rebuild the menu so status reflects fresh snapshots. - import threading - - def poller() -> None: - import time - while not quit_flag["stop"]: - time.sleep(poll_seconds) - try: - icon.menu = build_menu() - icon.update_menu() - except Exception: - # Pystray internals are toolkit-specific; swallow to - # keep the loop alive on transient errors. - pass - - t = threading.Thread(target=poller, daemon=True) - t.start() - icon.run() - return 0 + act.triggered.connect(lambda *_, c=cmd: run_bgo(c)) + menu.addAction(act) + held.append(act) + + return menu + + def rebuild() -> None: + """Replace the tray's menu with a fresh snapshot-derived build.""" + # Drop old refs so they can be reclaimed. Qt will free them + # once the previous menu's slots stop firing. + held.clear() + new_menu = build_menu() + held.append(new_menu) + tray.setContextMenu(new_menu) + + rebuild() + tray.show() + + # QTimer keeps the polling on Qt's main thread; no GIL juggling, + # no thread-unsafe menu mutation. + timer = QTimer() + timer.setInterval(max(1, poll_seconds) * 1000) + timer.timeout.connect(rebuild) + timer.start() + held.append(timer) + + return int(app.exec()) def run(poll_seconds: int | None = None) -> int: diff --git a/src/bgo_cli/_tray_install.py b/src/bgo_cli/_tray_install.py index bf4a2dd..9e322ea 100644 --- a/src/bgo_cli/_tray_install.py +++ b/src/bgo_cli/_tray_install.py @@ -1,14 +1,14 @@ """Auto-install logic for the optional ``[tray]`` extra. -The tray UI needs ``pystray`` and ``Pillow`` — both heavy enough that -we intentionally keep them out of the default install. When the user -runs ``bgo tray`` without those deps present, this module detects how -``bgo`` itself was installed and proposes the matching install -command: +The tray UI needs ``PySide6`` (Qt for Python, LGPL). It's heavy +enough that we intentionally keep it out of the default install. +When the user runs ``bgo tray`` without it present, this module +detects how ``bgo`` itself was installed and proposes the matching +install command: -* ``uv tool`` -> ``uv tool install --upgrade --with pystray --with Pillow bgo-cli`` -* ``pipx`` -> ``pipx inject bgo-cli pystray Pillow`` -* anything else -> ``pip install --user pystray Pillow`` +* ``uv tool`` -> ``uv tool install --upgrade --with PySide6 bgo-cli`` +* ``pipx`` -> ``pipx inject bgo-cli PySide6`` +* anything else -> ``pip install --user PySide6`` Detection is heuristic but reliable in practice: each installer leaves its name in ``sys.prefix`` (e.g. ``~/.local/share/uv/tools/bgo-cli``). @@ -67,18 +67,18 @@ def detect_installer() -> InstallerKind: def _command_for(installer: InstallerKind) -> list[str]: - """Return the argv that injects ``pystray`` + ``Pillow``.""" + """Return the argv that injects ``PySide6``.""" if installer == "uv": return [ "uv", "tool", "install", "--upgrade", - "--with", "pystray", "--with", "Pillow", + "--with", "PySide6", "bgo-cli", ] if installer == "pipx": - return ["pipx", "inject", "bgo-cli", "pystray", "Pillow"] + return ["pipx", "inject", "bgo-cli", "PySide6"] return [ sys.executable, "-m", "pip", "install", "--user", - "pystray", "Pillow", + "PySide6", ] @@ -125,8 +125,10 @@ def ensure_installed(auto: bool = False) -> bool: continue in-process — but re-exec is still the safe default. """ try: - import pystray # noqa: F401 (presence check only) - import PIL # noqa: F401 + import PySide6 # noqa: F401 (presence check only) + # PySide6 imports lazily; verify the QtWidgets submodule loads + # too so we catch broken installs early. + from PySide6 import QtWidgets # noqa: F401 return True except ImportError: pass @@ -135,9 +137,9 @@ def ensure_installed(auto: bool = False) -> bool: argv = _command_for(installer) print( - f"{_ansi('yellow', '⚠')} Tray requires " - f"{_ansi('bold', 'pystray')} and {_ansi('bold', 'Pillow')}. " - f"Detected installer: {_ansi('bold', installer)}." + f"{_ansi('yellow', '⚠')} Tray requires {_ansi('bold', 'PySide6')} " + f"(Qt for Python, LGPL). Detected installer: " + f"{_ansi('bold', installer)}." ) print(f" Proposed command: {_ansi('bold', ' '.join(argv))}") diff --git a/tests/test_tray.py b/tests/test_tray.py index a84bb89..00190e0 100644 --- a/tests/test_tray.py +++ b/tests/test_tray.py @@ -1,7 +1,7 @@ """Tests for the pure (toolkit-free) helpers in ``bgo_cli._tray``. -Anything that needs ``pystray`` or ``PIL`` lives in ``_run_tray`` and -is intentionally not covered here — it requires the optional extra +Anything that needs ``PySide6`` (Qt) lives in ``_run_tray`` and is +intentionally not covered here — it requires the optional extra ``bgo-cli[tray]`` and is exercised by the smoke-run in a dev install. """ @@ -162,3 +162,5 @@ def test_run_bgo_falls_back_to_argv0_when_which_misses( with mock.patch.object(_tray.subprocess, "run", return_value=completed) as run: _tray.run_bgo("ls") assert run.call_args.args[0][0] == str(fake.resolve()) + + diff --git a/tests/test_tray_install.py b/tests/test_tray_install.py index e9c5021..29f0d8c 100644 --- a/tests/test_tray_install.py +++ b/tests/test_tray_install.py @@ -54,14 +54,13 @@ def test_detect_installer_honors_uv_tool_dir_env( def test_command_for_uv() -> None: argv = _tray_install._command_for("uv") assert argv[:3] == ["uv", "tool", "install"] - assert "pystray" in argv - assert "Pillow" in argv + assert "PySide6" in argv assert argv[-1] == "bgo-cli" def test_command_for_pipx() -> None: assert _tray_install._command_for("pipx") == [ - "pipx", "inject", "bgo-cli", "pystray", "Pillow" + "pipx", "inject", "bgo-cli", "PySide6" ] @@ -70,8 +69,7 @@ def test_command_for_pip_uses_current_python(monkeypatch: pytest.MonkeyPatch) -> argv = _tray_install._command_for("pip") assert argv[:3] == ["/python", "-m", "pip"] assert "--user" in argv - assert "pystray" in argv - assert "Pillow" in argv + assert "PySide6" in argv # --- _installer_available ----------------------------------------------- @@ -97,11 +95,13 @@ def test_installer_available_pip_always_true() -> None: def test_ensure_installed_short_circuits_when_deps_present( monkeypatch: pytest.MonkeyPatch, ) -> None: - """If pystray + PIL import cleanly, no install command runs.""" - fake_pystray = mock.MagicMock() - fake_pil = mock.MagicMock() - monkeypatch.setitem(_tray_install.sys.modules, "pystray", fake_pystray) - monkeypatch.setitem(_tray_install.sys.modules, "PIL", fake_pil) + """If PySide6 imports cleanly, no install command runs.""" + fake_pyside = mock.MagicMock() + fake_qtwidgets = mock.MagicMock() + monkeypatch.setitem(_tray_install.sys.modules, "PySide6", fake_pyside) + monkeypatch.setitem( + _tray_install.sys.modules, "PySide6.QtWidgets", fake_qtwidgets + ) with mock.patch.object(_tray_install.subprocess, "run") as run: ok = _tray_install.ensure_installed(auto=False) assert ok is True @@ -113,17 +113,19 @@ def test_ensure_installed_short_circuits_when_deps_present( @pytest.fixture def missing_deps(monkeypatch: pytest.MonkeyPatch) -> None: - """Force the ``import pystray`` line inside ``ensure_installed`` to fail.""" + """Force the ``import PySide6`` line inside ``ensure_installed`` to fail.""" # Drop any cached modules from the importer. - monkeypatch.delitem(_tray_install.sys.modules, "pystray", raising=False) - monkeypatch.delitem(_tray_install.sys.modules, "PIL", raising=False) + monkeypatch.delitem(_tray_install.sys.modules, "PySide6", raising=False) + monkeypatch.delitem( + _tray_install.sys.modules, "PySide6.QtWidgets", raising=False + ) real_import = __builtins__["__import__"] if isinstance( __builtins__, dict ) else __import__ def fake_import(name, *args, **kwargs): # type: ignore[no-untyped-def] - if name in ("pystray", "PIL"): + if name == "PySide6" or name.startswith("PySide6."): raise ImportError(f"no module named {name!r}") return real_import(name, *args, **kwargs) From 8d739f084f798007ca7de91a62c450495e7960b7 Mon Sep 17 00:00:00 2001 From: tcsenpai Date: Tue, 19 May 2026 12:52:22 +0200 Subject: [PATCH 09/16] feat(tray): open logs in terminal, left-click menu, gear+dot dynamic icon Three tray UX improvements in one pass: 1. **Open logs** no longer opens the raw log file in $EDITOR. Instead it spawns 'bgo logs -f' in a fresh terminal window so the user gets the project's formatted live tail. Linux probes a curated list of emulators (kitty, alacritty, wezterm, foot, ghostty, gnome-terminal, konsole, xfce4-terminal, tilix, xterm) and picks the first one on PATH. macOS uses AppleScript to spawn Terminal.app, with BGO_TERMINAL=iterm switching to iTerm2. BGO_TERMINAL also accepts arbitrary 'binary [exec-flag]' values for non-default emulators. 2. **Left-click activation**. By default QSystemTrayIcon only opens the context menu on right-click; left- and middle-click did nothing. Wired tray.activated to popup the menu at the cursor on Trigger / MiddleClick activation reasons. Same menu, both buttons. 3. **Gear+dot icon with status-driven dot color**: - green (#3ddc84) when at least one proc runs and none errored - red (#ff5252) when any proc is errored - gray (#9e9e9e) when empty or all stopped Icon is regenerated only when the aggregate status changes (no wasted SVG rasterization on every 3s poll). Tooltip echoes the status string for monochrome themes that recolor icons. Implementation notes: - _icon_svg(color) builds the SVG on the fly; _aggregate_status reduces a snapshot list to one of {online, errored, idle}. Both are pure, unit-tested without Qt. - build_menu was renamed to build_menu_from(snapshots) so rebuild loads procs once per tick and feeds them to both the icon-color decision and the menu construction. - Added 11 new tests covering terminal resolution, log-opening on Linux/macOS, missing-terminal hint, aggregate status, and icon SVG rendering. 150 -> 166 tests. --- src/bgo_cli/_tray.py | 243 +++++++++++++++++++++++++++++++++++-------- tests/test_tray.py | 186 +++++++++++++++++++++++++++++++++ 2 files changed, 387 insertions(+), 42 deletions(-) diff --git a/src/bgo_cli/_tray.py b/src/bgo_cli/_tray.py index 7f53fb4..f3f1a87 100644 --- a/src/bgo_cli/_tray.py +++ b/src/bgo_cli/_tray.py @@ -173,30 +173,122 @@ def run_bgo(*args: str) -> int: return proc.returncode +# Ordered preferences for graphical terminal emulators. We pick the +# first one on PATH. Each tuple is (binary, exec-flag) — the flag is +# whatever the terminal uses to mean "the rest of the argv is the +# command to run". This list deliberately favors modern terminals +# (kitty/wezterm/alacritty/foot) before the desktop-environment +# defaults so users with both installed get the lighter one. +_LINUX_TERMINALS: tuple[tuple[str, str], ...] = ( + ("kitty", "--"), + ("alacritty", "-e"), + ("wezterm", "start"), + ("foot", "--"), + ("ghostty", "-e"), + ("gnome-terminal", "--"), + ("konsole", "-e"), + ("xfce4-terminal", "-e"), + ("tilix", "-e"), + ("xterm", "-e"), +) + + +def _resolve_terminal() -> tuple[str, str] | None: + """Pick the first available terminal emulator on Linux. + + Honors ``$BGO_TERMINAL`` for an explicit override; the value is + parsed as `` [exec-flag]`` (defaulting to ``-e`` when no + flag is given). Returns ``None`` if nothing usable was found. + """ + override = (os.environ.get("BGO_TERMINAL") or "").strip() + if override: + parts = override.split(None, 1) + binary = parts[0] + flag = parts[1] if len(parts) > 1 else "-e" + if shutil.which(binary): + return binary, flag + return None + for binary, flag in _LINUX_TERMINALS: + if shutil.which(binary): + return binary, flag + return None + + def _open_logs(name: str) -> None: - """Open the proc's stdout log in ``$EDITOR`` or the OS default. + """Open ``bgo logs -f`` in a fresh terminal window. - Failures are reported to stderr so the user has *some* signal when - both the editor and the OS default opener fail (e.g. headless - server with no xdg-open). They do not propagate — the tray loop - must stay alive. + macOS uses AppleScript to spawn Terminal.app (or iTerm2 if it's + set as default via ``$BGO_TERMINAL=iterm``). Linux probes a + curated list of common emulators and runs the first one found. + Any failure is written to stderr — the tray loop must stay alive. """ log = BGO_DIR / "logs" / f"{name}.out.log" if not log.exists(): sys.stderr.write(f"bgo tray: no log file for {name}\n") return - editor = os.environ.get("EDITOR") - if editor: - try: - subprocess.Popen([editor, str(log)]) - return - except OSError as exc: - sys.stderr.write(f"bgo tray: $EDITOR ({editor}) failed: {exc}\n") - opener = "open" if sys.platform == "darwin" else "xdg-open" + + bgo_bin = shutil.which("bgo") or str(Path(sys.argv[0]).resolve()) + follow_cmd = [bgo_bin, "logs", name, "-f"] + + if sys.platform == "darwin": + _open_logs_darwin(follow_cmd) + return + + terminal = _resolve_terminal() + if terminal is None: + sys.stderr.write( + "bgo tray: no terminal emulator found on PATH. " + "Set $BGO_TERMINAL to override (e.g. BGO_TERMINAL='alacritty -e').\n" + ) + return + + binary, flag = terminal + argv = [binary, flag, *follow_cmd] + try: + subprocess.Popen( + argv, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except OSError as exc: + sys.stderr.write(f"bgo tray: {binary} failed to launch: {exc}\n") + + +def _open_logs_darwin(follow_cmd: list[str]) -> None: + """Spawn ``follow_cmd`` in a new Terminal.app (or iTerm) window. + + AppleScript is the only friction-free way to open a fresh window + on macOS without writing a temporary launcher script. The command + is shell-quoted so paths with spaces survive the round-trip. + """ + import shlex + + quoted = " ".join(shlex.quote(arg) for arg in follow_cmd) + override = (os.environ.get("BGO_TERMINAL") or "").strip().lower() + use_iterm = override in ("iterm", "iterm2") + + if use_iterm: + script = ( + 'tell application "iTerm"\n' + " activate\n" + " create window with default profile\n" + f' tell current session of current window to write text "{quoted}"\n' + "end tell" + ) + else: + script = f'tell application "Terminal" to do script "{quoted}"' + try: - subprocess.Popen([opener, str(log)]) + subprocess.Popen( + ["osascript", "-e", script], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) except OSError as exc: - sys.stderr.write(f"bgo tray: {opener} failed: {exc}\n") + sys.stderr.write(f"bgo tray: osascript failed: {exc}\n") def _poll_interval() -> int: @@ -214,17 +306,57 @@ def _poll_interval() -> int: # --- PySide6 glue -------------------------------------------------------- -# SVG icon embedded as a string so we don't ship binary assets. Rendered -# in monochrome white-on-transparent, sized 64×64 so Qt downscales -# cleanly to 16/22/24 px tray slots. -_ICON_SVG = b"""\ - - - - - - -""" +# Status -> hex color for the gear's center dot. Chosen for contrast +# against most tray backgrounds (both dark and light themes) and for +# common color-blindness friendliness (green/red is suboptimal but +# universally understood for status — paired with shape would be +# better in a future revision). +_DOT_COLORS: dict[str, str] = { + "online": "#3ddc84", # bright green — at least one proc running, none errored + "errored": "#ff5252", # red — any proc in errored state + "idle": "#9e9e9e", # neutral gray — nothing registered or everything stopped +} + + +def _icon_svg(dot_color: str) -> bytes: + """Render the gear+dot SVG with the given dot color. + + The gear silhouette is a 12-tooth cog drawn with `path` data so it + scales crisply down to 16px. The center dot is a circle so it + renders solid even at the smallest size. + """ + return ( + '' + '' + # 12-tooth gear outline — symmetric, balanced visual weight. + '' + # Status dot — colored, sized to fill the inner ring cleanly. + f'' + '' + ).encode("utf-8") + + +def _aggregate_status(snapshots: list[ProcSnapshot]) -> str: + """Reduce a snapshot list to one status key for the icon dot. + + - ``errored`` : any proc flagged ``errored`` (highest priority) + - ``online`` : at least one proc running, none errored + - ``idle`` : empty list or all procs stopped + """ + if any(s.errored for s in snapshots): + return "errored" + if any(s.status == "running" for s in snapshots): + return "online" + return "idle" def _print_gnome_extension_hint() -> None: @@ -279,20 +411,21 @@ def _run_tray(poll_seconds: int) -> int: # pragma: no cover — needs Qt _print_gnome_extension_hint() return 1 - # Render the embedded SVG into a QIcon at a useful base size. Qt - # picks the closest match for the tray slot at draw time. - renderer = QSvgRenderer(QByteArray(_ICON_SVG)) - pixmap = QPixmap(QSize(64, 64)) - pixmap.fill(Qt.transparent) - painter = QPainter(pixmap) - try: - renderer.render(painter) - finally: - painter.end() - icon_image = QIcon(pixmap) + def make_icon(dot_color: str) -> QIcon: + """Rasterize the gear+dot SVG into a Qt icon at 64×64.""" + renderer = QSvgRenderer(QByteArray(_icon_svg(dot_color))) + pixmap = QPixmap(QSize(64, 64)) + pixmap.fill(Qt.transparent) + painter = QPainter(pixmap) + try: + renderer.render(painter) + finally: + painter.end() + return QIcon(pixmap) - tray = QSystemTrayIcon(icon_image) + tray = QSystemTrayIcon(make_icon(_DOT_COLORS["idle"])) tray.setToolTip("bgo") + current_status = {"key": "idle"} # We have to hold a reference to QMenu and every QAction (Qt # doesn't take ownership when added to a tray). Keeping them on a @@ -326,8 +459,8 @@ def make_proc_submenu(snap: ProcSnapshot, parent: QMenu) -> QMenu: held.extend([primary, stop, logs, sub]) return sub - def build_menu() -> QMenu: - spec = build_menu_spec(load_snapshots()) + def build_menu_from(snapshots: list[ProcSnapshot]) -> QMenu: + spec = build_menu_spec(snapshots) menu = QMenu() title_action = QAction(spec.title, menu) title_action.setEnabled(False) @@ -356,14 +489,40 @@ def build_menu() -> QMenu: return menu def rebuild() -> None: - """Replace the tray's menu with a fresh snapshot-derived build.""" + """Replace the tray's menu and icon from the latest snapshot.""" # Drop old refs so they can be reclaimed. Qt will free them # once the previous menu's slots stop firing. held.clear() - new_menu = build_menu() + snapshots = load_snapshots() + status_key = _aggregate_status(snapshots) + if status_key != current_status["key"]: + tray.setIcon(make_icon(_DOT_COLORS[status_key])) + current_status["key"] = status_key + # Tooltip echoes current status for users whose tray hides + # color cues (some monochrome themes recolor icons). + tray.setToolTip(f"bgo — {status_key}") + new_menu = build_menu_from(snapshots) held.append(new_menu) tray.setContextMenu(new_menu) + def on_activated(reason: "QSystemTrayIcon.ActivationReason") -> None: + """Show the context menu on left-click (Trigger), not just right-click. + + Right-click already pops the context menu via Qt's built-in + handling. Left-click (``Trigger``) and middle-click do + nothing by default — wiring them to ``popup()`` at the + current cursor matches what most tray apps do. + """ + if reason in ( + QSystemTrayIcon.ActivationReason.Trigger, + QSystemTrayIcon.ActivationReason.MiddleClick, + ): + menu = tray.contextMenu() + if menu is not None: + from PySide6.QtGui import QCursor + menu.popup(QCursor.pos()) + + tray.activated.connect(on_activated) rebuild() tray.show() diff --git a/tests/test_tray.py b/tests/test_tray.py index 00190e0..b59c892 100644 --- a/tests/test_tray.py +++ b/tests/test_tray.py @@ -164,3 +164,189 @@ def test_run_bgo_falls_back_to_argv0_when_which_misses( assert run.call_args.args[0][0] == str(fake.resolve()) +# --- _resolve_terminal -------------------------------------------------- + + +def test_resolve_terminal_returns_none_when_nothing_found( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("BGO_TERMINAL", raising=False) + monkeypatch.setattr(_tray.shutil, "which", lambda _: None) + assert _tray._resolve_terminal() is None + + +def test_resolve_terminal_picks_first_available( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Probes in order; the first hit wins.""" + monkeypatch.delenv("BGO_TERMINAL", raising=False) + + # Only ``foot`` is on PATH — it should win over later xterm even + # though both are in the candidate list. + def fake_which(binary: str) -> str | None: + return "/usr/bin/foot" if binary == "foot" else None + + monkeypatch.setattr(_tray.shutil, "which", side_effect=fake_which) \ + if hasattr(monkeypatch, "setattr_side_effect") else monkeypatch.setattr( + _tray.shutil, "which", fake_which + ) + result = _tray._resolve_terminal() + assert result == ("foot", "--") + + +def test_resolve_terminal_honors_env_override( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("BGO_TERMINAL", "alacritty -e") + monkeypatch.setattr(_tray.shutil, "which", lambda _: "/usr/bin/alacritty") + assert _tray._resolve_terminal() == ("alacritty", "-e") + + +def test_resolve_terminal_override_defaults_to_dash_e_when_unspecified( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("BGO_TERMINAL", "weirdterm") + monkeypatch.setattr(_tray.shutil, "which", lambda _: "/x/weirdterm") + assert _tray._resolve_terminal() == ("weirdterm", "-e") + + +def test_resolve_terminal_override_not_found_returns_none( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Explicit override misses -> None, not silent fallback.""" + monkeypatch.setenv("BGO_TERMINAL", "missing-term") + monkeypatch.setattr(_tray.shutil, "which", lambda _: None) + assert _tray._resolve_terminal() is None + + +# --- _open_logs --------------------------------------------------------- + + +def test_open_logs_warns_when_no_log_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], +) -> None: + """Missing log file -> stderr hint, no terminal launched.""" + monkeypatch.setattr(_tray, "BGO_DIR", tmp_path) + with mock.patch.object(_tray.subprocess, "Popen") as popen: + _tray._open_logs("nope") + popen.assert_not_called() + err = capsys.readouterr().err + assert "no log file" in err + + +def test_open_logs_linux_spawns_terminal_with_follow_command( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Linux path: invoke the resolved terminal with ``bgo logs -f``.""" + monkeypatch.setattr(_tray, "BGO_DIR", tmp_path) + (tmp_path / "logs").mkdir() + (tmp_path / "logs" / "web.out.log").write_text("") + monkeypatch.setattr(_tray.sys, "platform", "linux") + monkeypatch.setattr(_tray.shutil, "which", lambda b: "/bin/bgo" if b == "bgo" else "/usr/bin/kitty") + monkeypatch.setattr(_tray, "_resolve_terminal", lambda: ("kitty", "--")) + with mock.patch.object(_tray.subprocess, "Popen") as popen: + _tray._open_logs("web") + popen.assert_called_once() + argv = popen.call_args.args[0] + assert argv[0] == "kitty" + assert argv[1] == "--" + assert argv[-3:] == ["logs", "web", "-f"] + + +def test_open_logs_linux_no_terminal_writes_hint( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setattr(_tray, "BGO_DIR", tmp_path) + (tmp_path / "logs").mkdir() + (tmp_path / "logs" / "web.out.log").write_text("") + monkeypatch.setattr(_tray.sys, "platform", "linux") + monkeypatch.setattr(_tray, "_resolve_terminal", lambda: None) + with mock.patch.object(_tray.subprocess, "Popen") as popen: + _tray._open_logs("web") + popen.assert_not_called() + err = capsys.readouterr().err + assert "BGO_TERMINAL" in err + + +def test_open_logs_darwin_uses_osascript( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_tray, "BGO_DIR", tmp_path) + (tmp_path / "logs").mkdir() + (tmp_path / "logs" / "api.out.log").write_text("") + monkeypatch.setattr(_tray.sys, "platform", "darwin") + monkeypatch.delenv("BGO_TERMINAL", raising=False) + monkeypatch.setattr(_tray.shutil, "which", lambda _: "/bin/bgo") + with mock.patch.object(_tray.subprocess, "Popen") as popen: + _tray._open_logs("api") + argv = popen.call_args.args[0] + assert argv[0] == "osascript" + assert "Terminal" in argv[-1] + assert "logs api -f" in argv[-1] + + +# --- _aggregate_status -------------------------------------------------- + + +def test_aggregate_status_empty_is_idle() -> None: + assert _tray._aggregate_status([]) == "idle" + + +def test_aggregate_status_all_stopped_is_idle() -> None: + snaps = [ + _tray.ProcSnapshot("a", "stopped", None, errored=False), + _tray.ProcSnapshot("b", "stopped", None, errored=False), + ] + assert _tray._aggregate_status(snaps) == "idle" + + +def test_aggregate_status_any_running_is_online() -> None: + snaps = [ + _tray.ProcSnapshot("a", "stopped", None, errored=False), + _tray.ProcSnapshot("b", "running", 100, errored=False), + ] + assert _tray._aggregate_status(snaps) == "online" + + +def test_aggregate_status_errored_dominates_running() -> None: + """Errored is highest priority, even if other procs are healthy.""" + snaps = [ + _tray.ProcSnapshot("a", "running", 100, errored=False), + _tray.ProcSnapshot("b", "stopped", None, errored=True), + ] + assert _tray._aggregate_status(snaps) == "errored" + + +# --- _icon_svg ---------------------------------------------------------- + + +def test_icon_svg_embeds_requested_color() -> None: + """The dot color reaches the rendered SVG verbatim.""" + svg = _tray._icon_svg("#3ddc84") + assert b'fill="#3ddc84"' in svg + assert svg.startswith(b" None: + """Three statuses produce three distinct icons.""" + seen = {_tray._icon_svg(c) for c in _tray._DOT_COLORS.values()} + assert len(seen) == len(_tray._DOT_COLORS) + + +def test_open_logs_darwin_iterm_override( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_tray, "BGO_DIR", tmp_path) + (tmp_path / "logs").mkdir() + (tmp_path / "logs" / "api.out.log").write_text("") + monkeypatch.setattr(_tray.sys, "platform", "darwin") + monkeypatch.setenv("BGO_TERMINAL", "iterm") + monkeypatch.setattr(_tray.shutil, "which", lambda _: "/bin/bgo") + with mock.patch.object(_tray.subprocess, "Popen") as popen: + _tray._open_logs("api") + argv = popen.call_args.args[0] + assert "iTerm" in argv[-1] + + From 2e2ac1da887bf311defc892715f9fe93c25d08b2 Mon Sep 17 00:00:00 2001 From: tcsenpai Date: Tue, 19 May 2026 12:57:26 +0200 Subject: [PATCH 10/16] fix(tray): better icon, status glyphs in menu, exec() for reliable left-click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three reported issues addressed: 1. **Icon was gear teeth with blue center**. The original SVG used a single path with fill-rule=evenodd to cut the inner hole; Qt's QSvgRenderer + KDE Plasma's monochrome SNI repaint pass renders that as solid white gear, after which Plasma tints it accent-blue because it reads as monochrome. Redrawn as: - a stroked ring (the gear body), - 12 separate radial rect 'teeth', - a colored center dot. No path subtraction, no evenodd. Three explicit colors (white teeth + colored dot) means hosts no longer detect the icon as monochrome and stop recoloring it. 2. **No status colors in menu**. Qt QAction does not honor per-item foreground color across themes. Added Unicode status glyph prefix instead: '● myproc · online' / '○ myproc · stopped' / '⚠ myproc · errored'. Shape-based status communication works on every platform/theme; glyphs render reliably in KDE, GNOME, and macOS menus. 3. **Left-click didn't open the menu** on KDE Plasma. Replaced QMenu.popup() (async, gets eaten by some SNI hosts before paint) with QMenu.exec() (synchronous, force-shows). Added BGO_TRAY_DEBUG env var that logs the activation reason to stderr so users can diagnose host-specific quirks. Tests: +4 (_status_glyph matrix). 166 -> 169. README updated with full new tray behavior (icon dot colors, menu glyphs, terminal-emulator probing for Open-logs, left/middle/right click activation, BGO_TERMINAL + BGO_TRAY_DEBUG envs, autostart and 'bgo start -w' background examples). Also fixed the stale test count (54 -> 169) and Python requirement (3.9+ -> 3.10+, matching pyproject.toml since main's CI matrix dropped 3.9). --- README.md | 63 +++++++++++++++++++++++---- src/bgo_cli/_tray.py | 101 +++++++++++++++++++++++++++++++++---------- tests/test_tray.py | 14 ++++++ 3 files changed, 146 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 1e6a5f6..5d4c6d6 100644 --- a/README.md +++ b/README.md @@ -291,14 +291,42 @@ Neither flag is set automatically — it modifies system state. ## Tray icon (optional) -`bgo tray` runs a system-tray icon that lists all registered procs -and exposes Start / Stop / Restart / Open-logs actions for each. The -menu refreshes from `~/.bgo/procs/*.json` every few seconds and -shells out to `bgo` for every action, so behavior matches the CLI -exactly. +`bgo tray` runs a system-tray icon (a gear with a colored status dot) +that lists all registered procs and exposes one-click actions for each. + +**Icon dot color**: +- 🟢 green — at least one proc running, none errored +- 🔴 red — any proc errored +- ⚫ gray — empty or all stopped + +**Menu**: each proc gets a submenu prefixed with a Unicode status glyph +(● online / ○ stopped / ⚠ errored). The submenu offers Start (or +Restart when online) / Stop / Open logs. Global entries: Resurrect all +/ Refresh now / Quit. The menu rebuilds from `~/.bgo/procs/*.json` +every few seconds; every action shells out to `bgo` so behavior +matches the CLI exactly. + +**Open logs** spawns `bgo logs -f` in a new terminal window +(live tail with formatting), not the raw log file in an editor. Linux +probes a curated list (kitty, alacritty, wezterm, foot, ghostty, +gnome-terminal, konsole, xfce4-terminal, tilix, xterm) and picks the +first one on PATH. macOS uses AppleScript to spawn Terminal.app. Set +`BGO_TERMINAL='kitty --'` (or any `binary [exec-flag]`) for an +explicit override; `BGO_TERMINAL=iterm` switches macOS to iTerm2. + +**Left- and right-click** both open the menu. Middle-click does too. +Right-click is always handled by the host directly. Left/middle-click +is wired by us via Qt's `activated` signal — most hosts deliver it, +but a few (notably some KDE Plasma configurations that bind a custom +left-click action) may swallow it; in that case right-click still +works and the host setting is overrideable in tray config. Set +`BGO_TRAY_DEBUG=1` to log the activation reason to stderr while +debugging. + +### Platform support Tray UI uses **PySide6** (Qt for Python, LGPL). One library, native -support on every platform we target: +support on every target: | Platform | What happens | |---|---| @@ -307,6 +335,8 @@ support on every platform we target: | **Hyprland / sway + waybar** | SNI via waybar's `tray` module | | **GNOME Wayland** | Needs the AppIndicator shell extension (see below) | +### Install + PySide6 is shipped as an optional extra to keep the core install zero-dep: @@ -326,6 +356,20 @@ If you forget, the first run of `bgo tray` detects your installer (uv tool / pipx / pip) and offers to inject the dep for you. Skip the confirmation with `--auto-install` or `BGO_TRAY_AUTOINSTALL=1`. +### Run in the background + +The tray is just another command — run it under `bgo` itself: + +```bash +bgo start -w bgotray -- bgo tray # -w auto-restarts on crash +``` + +Or, simpler, install it as a login autostart entry: + +```bash +bgo autostart install --tray # tray starts on every login +``` + ### GNOME Wayland prerequisite GNOME ships no system-tray host of its own. Install the @@ -346,6 +390,8 @@ step. |---|---|---| | `--poll N`, `BGO_TRAY_POLL` | `3` | Snapshot refresh interval (seconds) | | `--auto-install`, `BGO_TRAY_AUTOINSTALL=1` | off | Skip the install prompt | +| `BGO_TERMINAL` | (auto-probe) | Terminal emulator for Open-logs (`'kitty --'`, `iterm`, etc.) | +| `BGO_TRAY_DEBUG=1` | off | Log activation reasons to stderr | ## Storage @@ -358,12 +404,13 @@ step. python3 -m pytest tests/ -v ``` -54 tests covering state I/O, atomic writes, command-shape detection, name derivation, liveness + zombie filtering, watch-config inheritance, and table rendering across all three levels. +169 tests covering state I/O, atomic writes, command-shape detection, name derivation, liveness + zombie filtering, watch-config inheritance, table rendering, desktop notifications, login autostart (systemd-user / launchd), and tray menu construction. ## Requirements -- Python 3.9+ +- Python 3.10+ - Linux or macOS (zombie detection is platform-aware: `/proc` on Linux, `ps -o stat=` on macOS) +- Optional `[tray]` extra: PySide6 (Qt for Python, LGPL) ## License diff --git a/src/bgo_cli/_tray.py b/src/bgo_cli/_tray.py index f3f1a87..f2d233e 100644 --- a/src/bgo_cli/_tray.py +++ b/src/bgo_cli/_tray.py @@ -120,6 +120,22 @@ def _status_label(snap: ProcSnapshot) -> str: return "stopped" +# Unicode glyphs paired with status keys. QAction labels render +# Unicode reliably across KDE, GNOME, and macOS — the only way to get +# "color" in menu items without per-platform theming code is to use +# glyphs whose shape (filled vs hollow vs warning) communicates state. +_STATUS_GLYPHS: dict[str, str] = { + "online": "●", # filled circle — running + "stopped": "○", # hollow circle — stopped + "errored": "⚠", # warning sign — errored +} + + +def _status_glyph(snap: ProcSnapshot) -> str: + """Return the Unicode glyph for the snap's status.""" + return _STATUS_GLYPHS[_status_label(snap)] + + def build_menu_spec(snapshots: Iterable[ProcSnapshot]) -> MenuSpec: """Render snapshots into a :class:`MenuSpec` for the toolkit layer. @@ -321,26 +337,47 @@ def _poll_interval() -> int: def _icon_svg(dot_color: str) -> bytes: """Render the gear+dot SVG with the given dot color. - The gear silhouette is a 12-tooth cog drawn with `path` data so it - scales crisply down to 16px. The center dot is a circle so it - renders solid even at the smallest size. + Composition strategy avoids the ``fill-rule=evenodd`` cutout that + some renderers (notably Qt's QSvgRenderer + KDE Plasma's monochrome + SNI repaint pass) mishandle: + + 1. **Ring** — a thick stroked circle = the gear body. + 2. **Teeth** — 12 small rectangles placed radially around the ring. + 3. **Dot** — colored circle in the middle, sitting on top. + + Each is a separate SVG element. Total: 14 shapes, all explicitly + filled, no path subtraction. Renders identically on Qt, KDE + monochrome repaint, and macOS Cocoa. """ + # 12 teeth at 30° intervals. Each tooth = 6×8 rect centered on a + # radial line at radius 26 from center (32, 32). + import math + + teeth = [] + for i in range(12): + angle = math.radians(i * 30) + # Place tooth so its base touches the outer edge of the ring. + cx = 32 + math.cos(angle) * 28 + cy = 32 + math.sin(angle) * 28 + # Rotate the rect to align with the radial direction. + deg = i * 30 + teeth.append( + f'' + ) + return ( '' '' - # 12-tooth gear outline — symmetric, balanced visual weight. - '' - # Status dot — colored, sized to fill the inner ring cleanly. - f'' + # Outer ring — white stroke, no fill (transparent center so + # the dot below shows through cleanly). + '' + # 12 teeth radiating outward. + + "".join(teeth) + + # Status dot — colored, fills the inner ring. + f'' '' ).encode("utf-8") @@ -469,7 +506,7 @@ def build_menu_from(snapshots: list[ProcSnapshot]) -> QMenu: held.append(title_action) for snap in spec.procs: - label = f"{snap.name} [{_status_label(snap)}]" + label = f"{_status_glyph(snap)} {snap.name} · {_status_label(snap)}" sub = make_proc_submenu(snap, menu) sub.setTitle(label) menu.addMenu(sub) @@ -506,13 +543,26 @@ def rebuild() -> None: tray.setContextMenu(new_menu) def on_activated(reason: "QSystemTrayIcon.ActivationReason") -> None: - """Show the context menu on left-click (Trigger), not just right-click. - - Right-click already pops the context menu via Qt's built-in - handling. Left-click (``Trigger``) and middle-click do - nothing by default — wiring them to ``popup()`` at the - current cursor matches what most tray apps do. + """Show the context menu on left-click and middle-click. + + Right-click is handled by Qt + the host natively (it always + opens the ``setContextMenu`` menu). Left-click (``Trigger``) + and middle-click do nothing by default, so we explicitly pop + the same menu at the cursor. + + Notes on host behavior: + - KDE Plasma's SNI host *should* deliver Trigger to Qt; if it + doesn't, the activation is being consumed by the host's own + left-click action (configurable in Plasma's tray settings). + - GNOME via AppIndicator typically routes left-click to the + menu directly without going through Qt's signal. + - macOS NSStatusItem always fires Trigger on a single click. + + ``$BGO_TRAY_DEBUG=1`` prints the received reason so users can + diagnose host-specific quirks. """ + if os.environ.get("BGO_TRAY_DEBUG"): + sys.stderr.write(f"bgo tray: activated reason={reason}\n") if reason in ( QSystemTrayIcon.ActivationReason.Trigger, QSystemTrayIcon.ActivationReason.MiddleClick, @@ -520,7 +570,10 @@ def on_activated(reason: "QSystemTrayIcon.ActivationReason") -> None: menu = tray.contextMenu() if menu is not None: from PySide6.QtGui import QCursor - menu.popup(QCursor.pos()) + # exec() is blocking but force-shows the menu reliably + # across SNI hosts. popup() is async and gets eaten by + # some compositors before paint. + menu.exec(QCursor.pos()) tray.activated.connect(on_activated) rebuild() diff --git a/tests/test_tray.py b/tests/test_tray.py index b59c892..c0ee932 100644 --- a/tests/test_tray.py +++ b/tests/test_tray.py @@ -103,6 +103,20 @@ def test_status_label_matrix(status: str, errored: bool, expected: str) -> None: assert _tray._status_label(snap) == expected +@pytest.mark.parametrize( + ("status", "errored", "expected_glyph"), + [ + ("running", False, "●"), + ("running", True, "⚠"), + ("stopped", False, "○"), + ], +) +def test_status_glyph_matrix(status: str, errored: bool, expected_glyph: str) -> None: + """Each status maps to a distinct Unicode glyph for the menu.""" + snap = _tray.ProcSnapshot("x", status, 1, errored=errored) + assert _tray._status_glyph(snap) == expected_glyph + + # --- _poll_interval ----------------------------------------------------- From 66fafd4befe9a76861cfe5978dd64cc788b2d3ff Mon Sep 17 00:00:00 2001 From: tcsenpai Date: Tue, 19 May 2026 13:06:28 +0200 Subject: [PATCH 11/16] refactor(#6): extract _term, _state, _proc, _watcher modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Begin modularization of the 2070-line root 'bgo' script per fu #6. Source-of-truth for each concern moves into a focused module under src/bgo_cli/; the root script re-imports the names so its public namespace (and the test fixture's introspection of it) keeps working unchanged. Modules extracted in this pass: - _term.py (145 LOC) — COLORS, ANSI_RE, color(), strip_ansi(), truncate(), LEVEL_*, _detect_table_level(), GLYPHS, glyphs(). - _state.py (113 LOC) — BGO_DIR, PROCS_DIR, LOGS_DIR, init_dirs(), proc_file(), log_path(), watcher_log_path(), watcher_log(), load_proc(), save_proc(), delete_proc(), load_all_procs(). - _proc.py (191 LOC) — _is_zombie(), is_running(), _BLANK_PINFO, get_process_info(), get_process_info_batch(), _looks_like_command(), derive_name(), resolve_command(), kill_process(). - _watcher.py (410 LOC) — WATCH_DEFAULTS, BACKOFF_SCHEDULE, TAIL_BYTES, _notify_errored, _resolve_watch_block, _default_watch_config, _spawn_watcher, _kill_watcher, _tail_stderr, _restart_proc_inplace, cmd_watcher_loop. _spawn_watcher now resolves the bgo entrypoint via shutil.which ('bgo') with sys.argv[0] fallback, instead of os.path.abspath( __file__). The latter would point at _watcher.py once relocated; the former hits the user's installed CLI shim consistently across uv tool / pipx / pip / repo-checkout invocations. conftest.py now patches BGO_DIR / PROCS_DIR / LOGS_DIR on BOTH the loaded bgo module and bgo_cli._state (when present) so tests that mutate either binding stay consistent. bgo script: 2070 -> 1593 LOC. Next pass: extract cmd_* handlers, status rendering, interactive multiselect, and the argparse + direct-mode CLI front-end. All 169 tests pass unchanged. --- bgo | 734 ++++------------------------------------ src/bgo_cli/_proc.py | 191 +++++++++++ src/bgo_cli/_state.py | 113 +++++++ src/bgo_cli/_term.py | 145 ++++++++ src/bgo_cli/_watcher.py | 410 ++++++++++++++++++++++ tests/conftest.py | 22 +- 6 files changed, 943 insertions(+), 672 deletions(-) create mode 100644 src/bgo_cli/_proc.py create mode 100644 src/bgo_cli/_state.py create mode 100644 src/bgo_cli/_term.py create mode 100644 src/bgo_cli/_watcher.py diff --git a/bgo b/bgo index cf832dc..a997f43 100755 --- a/bgo +++ b/bgo @@ -19,674 +19,72 @@ from datetime import datetime, timezone from pathlib import Path -def _notify_errored(name: str, reason: str) -> None: - """Best-effort desktop notification on errored transition. - - Lazy-imports the optional ``bgo_cli._notify`` module so the bare - ``bgo`` script keeps working even outside its installed package - context. Any failure is swallowed — notifications must never break - the watcher loop. - """ - try: - from bgo_cli._notify import notify - except ImportError: - return - try: - notify(f"bgo: {name} errored", reason, level="error") - except Exception: - pass - -# Config -BGO_DIR = Path.home() / ".bgo" -PROCS_DIR = BGO_DIR / "procs" -LOGS_DIR = BGO_DIR / "logs" - -# ANSI colors -COLORS = { - "green": "\033[32m", - "red": "\033[31m", - "yellow": "\033[33m", - "blue": "\033[34m", - "gray": "\033[90m", - "reset": "\033[0m", - "bold": "\033[1m", -} - -ANSI_RE = re.compile(r"\033\[[0-9;]*m") - - -def color(name: str, text: str) -> str: - """Wrap text in color codes, only if stdout is a TTY.""" - if not sys.stdout.isatty(): - return str(text) - return f"{COLORS.get(name, '')}{text}{COLORS['reset']}" - - -def strip_ansi(s: str) -> str: - """Remove ANSI escape codes from a string.""" - return ANSI_RE.sub("", s) - - -def truncate(s: str, width: int) -> str: - """Truncate string to fit in width, accounting for ANSI codes.""" - plain = strip_ansi(s) - if len(plain) > width: - return s[: width - 3] + "..." - return s - - -# --- Terminal capability detection --- - -# Three levels: -# plain — no color, ASCII-only dashes, no glyphs (CI logs, dumb terms, -# non-TTY pipes) -# normal — ANSI color + ASCII dashes (default for color-capable TTYs) -# fancy — ANSI color + Unicode box-drawing + heavier visual structure -# (UTF-8-capable TTYs) -LEVEL_PLAIN = "plain" -LEVEL_NORMAL = "normal" -LEVEL_FANCY = "fancy" - - -def _detect_table_level(force: str | None = None) -> str: - """Decide which rendering level to use. - - Resolution order: - 1. explicit `force` (from --plain/--fancy or BGO_TABLE env) - 2. non-TTY stdout -> plain - 3. TERM=dumb -> plain - 4. LANG/LC_* lacks UTF-8 -> normal - 5. otherwise -> fancy - """ - if force in (LEVEL_PLAIN, LEVEL_NORMAL, LEVEL_FANCY): - return force - env_force = os.environ.get("BGO_TABLE", "").strip().lower() - if env_force in (LEVEL_PLAIN, LEVEL_NORMAL, LEVEL_FANCY): - return env_force - if not sys.stdout.isatty(): - return LEVEL_PLAIN - if os.environ.get("TERM", "") == "dumb": - return LEVEL_PLAIN - lc = ( - os.environ.get("LC_ALL") - or os.environ.get("LC_CTYPE") - or os.environ.get("LANG") - or "" - ).upper() - if "UTF-8" not in lc and "UTF8" not in lc: - return LEVEL_NORMAL - return LEVEL_FANCY - - -# Glyph set per level. Keys must be identical across levels so callers -# can index without branching. -GLYPHS = { - LEVEL_PLAIN: { - "hline": "-", "vline": "|", "cross": "+", - "tl": "+", "tr": "+", "bl": "+", "br": "+", - "tdown": "+", "tup": "+", "tleft": "+", "tright": "+", - "online": "ON", "stopped": "OFF", - "watching": "[W]", "errored": "[!]", "watcher_dead": "[?]", - "watch_none": "-", - "ok": "OK", "fail": "FAIL", "warn": "WARN", - "tombstone": "[X]", "rocket": "[+]", "eye": "[W]", - }, - LEVEL_NORMAL: { - "hline": "─", "vline": "│", "cross": "┼", - "tl": "┌", "tr": "┐", "bl": "└", "br": "┘", - "tdown": "┬", "tup": "┴", "tleft": "┤", "tright": "├", - "online": "online", "stopped": "stopped", - "watching": "✓", "errored": "⚠", "watcher_dead": "!", - "watch_none": "-", - "ok": "✅", "fail": "❌", "warn": "⚠️", - "tombstone": "🗑️", "rocket": "🚀", "eye": "👁", - }, - LEVEL_FANCY: { - "hline": "━", "vline": "┃", "cross": "╋", - "tl": "┏", "tr": "┓", "bl": "┗", "br": "┛", - "tdown": "┳", "tup": "┻", "tleft": "┫", "tright": "┣", - "online": "● online", "stopped": "○ stopped", - "watching": "✓", "errored": "⚠", "watcher_dead": "!", - "watch_none": "·", - "ok": "✅", "fail": "❌", "warn": "⚠️", - "tombstone": "🗑️", "rocket": "🚀", "eye": "👁", - }, -} - - -def glyphs(level: str | None = None) -> dict: - """Return the glyph set for a level (default: auto-detect).""" - return GLYPHS[level or _detect_table_level()] - - -def init_dirs(): - """Initialize required directories.""" - PROCS_DIR.mkdir(parents=True, exist_ok=True) - LOGS_DIR.mkdir(parents=True, exist_ok=True) - - -# --- State management (one file per process) --- - - -def proc_file(name: str) -> Path: - return PROCS_DIR / f"{name}.json" - - -def log_path(name: str, stream: str = "out") -> Path: - return LOGS_DIR / f"{name}.{stream}.log" - - -def watcher_log_path(name: str) -> Path: - return LOGS_DIR / f"{name}.watcher.log" - - -def watcher_log(name: str, msg: str): - """Append timestamped line to watcher log.""" - try: - ts = datetime.now().isoformat(timespec="seconds") - with open(watcher_log_path(name), "a") as f: - f.write(f"[{ts}] {msg}\n") - except OSError: - pass - - -def load_proc(name: str) -> dict | None: - pf = proc_file(name) - if not pf.exists(): - return None - try: - return json.loads(pf.read_text()) - except (json.JSONDecodeError, OSError): - return None - - -def save_proc(name: str, info: dict): - """Atomic write: tmp file + os.replace. - - Prevents torn JSON if the process is killed mid-write or if the - watcher and the foreground CLI both save concurrently. os.replace - is atomic on POSIX when src and dst are on the same filesystem, - which is guaranteed here (both under ~/.bgo/procs/). - """ - pf = proc_file(name) - tmp = pf.with_suffix(pf.suffix + ".tmp") - tmp.write_text(json.dumps(info, indent=2)) - os.replace(tmp, pf) - - -def delete_proc(name: str, keep_logs: bool = False): - """Remove proc state file. Logs removed unless keep_logs=True.""" - proc_file(name).unlink(missing_ok=True) - if keep_logs: - return - for stream in ("out", "err"): - log_path(name, stream).unlink(missing_ok=True) - watcher_log_path(name).unlink(missing_ok=True) - - -def load_all_procs() -> dict[str, dict]: - """Load all process states.""" - procs = {} - for pf in sorted(PROCS_DIR.glob("*.json")): - try: - info = json.loads(pf.read_text()) - procs[info.get("name", pf.stem)] = info - except (json.JSONDecodeError, OSError): - continue - return procs - - -# --- Utilities --- - - -def _is_zombie(pid: int) -> bool: - """Return True if pid is a zombie/defunct process. Platform-aware.""" - if sys.platform.startswith("linux"): - try: - with open(f"/proc/{pid}/stat", "r") as f: - stat = f.read() - # stat format: pid (comm) state ... comm may contain spaces/parens. - rparen = stat.rfind(")") - if rparen != -1: - state = stat[rparen + 2 : rparen + 3] - return state == "Z" - except (OSError, IndexError): - return False - return False - if sys.platform == "darwin": - try: - result = subprocess.run( - ["ps", "-p", str(pid), "-o", "stat=", ], - capture_output=True, text=True, timeout=2, - ) - if result.returncode == 0: - state = result.stdout.strip() - # macOS ps stat: 'Z' is zombie; may be prefixed with flags - return state.startswith("Z") - except (subprocess.SubprocessError, OSError): - return False - return False - - -def is_running(pid: int | None) -> bool: - """Return True if pid is alive AND not a zombie/defunct process.""" - if pid is None: - return False - try: - os.kill(pid, 0) - except (ProcessLookupError, OSError): - return False - return not _is_zombie(pid) - - -_BLANK_PINFO = {"cpu": "-", "mem": "-", "time": "-"} - - -def get_process_info(pid: int) -> dict: - """Get CPU/MEM/uptime for a single pid via ps. Prefer batch lookup.""" - return get_process_info_batch([pid]).get(pid, dict(_BLANK_PINFO)) - - -def get_process_info_batch(pids: list[int]) -> dict[int, dict]: - """Single ps call for many pids. Returns {pid: {cpu, mem, time}}.""" - result_map: dict[int, dict] = {} - if not pids: - return result_map - # ps -p accepts comma-separated pids on both Linux and macOS. - pid_arg = ",".join(str(p) for p in pids) - try: - result = subprocess.run( - ["ps", "-p", pid_arg, "-o", "pid,%cpu,%mem,etime", "--no-headers"], - capture_output=True, - text=True, - timeout=4, - ) - if result.returncode == 0: - for line in result.stdout.splitlines(): - parts = line.split(None, 3) - if len(parts) >= 4: - try: - result_map[int(parts[0])] = { - "cpu": parts[1], "mem": parts[2], "time": parts[3], - } - except ValueError: - continue - except (subprocess.SubprocessError, OSError): - pass - # Fill misses with blanks - for p in pids: - result_map.setdefault(p, dict(_BLANK_PINFO)) - return result_map - - -def _looks_like_command(arg: str) -> bool: - """Check if an argument looks like an executable command (not a plain name). - Returns True if the arg is a path, has an extension, or resolves via which.""" - if os.sep in arg or arg.startswith("./"): - return True - if "." in arg: - return True - if shutil.which(arg): - return True - return False - - -def derive_name(cmd: list[str]) -> str: - """Derive a process name from the command.""" - base = os.path.basename(cmd[0]) - for ext in (".py", ".sh", ".js", ".ts", ".rb", ".pl", ".exe"): - if base.endswith(ext): - base = base[: -len(ext)] - return base - - -def resolve_command(cmd: list[str]) -> list[str]: - """Resolve command to full path if possible.""" - binary = shutil.which(cmd[0]) - if binary: - cmd = cmd[:] - cmd[0] = binary - return cmd - - -def kill_process(pid: int, pgid: int | None, force: bool = False) -> bool: - """Kill a process (and its entire process group). Returns True if dead.""" - sig = signal.SIGKILL if force else signal.SIGTERM - - try: - if pgid: - os.killpg(pgid, sig) - else: - os.kill(pid, sig) - except ProcessLookupError: - return True - except PermissionError: - print(f"{color('red', '❌')} Permission denied killing PID {pid}") - return False - - # Wait for termination - for _ in range(50): # 5 seconds - if not is_running(pid): - return True - time.sleep(0.1) - - # Escalate to SIGKILL if SIGTERM didn't work - if not force and is_running(pid): - try: - if pgid: - os.killpg(pgid, signal.SIGKILL) - else: - os.kill(pid, signal.SIGKILL) - time.sleep(0.3) - except (ProcessLookupError, PermissionError): - pass - - return not is_running(pid) - - -# --- Watch helpers --- - - -WATCH_DEFAULTS = { - "interval": 3, - "min_uptime": 2, - "on_fast_crash": "backoff", # backoff | stop | retry -} -BACKOFF_SCHEDULE = [2, 4, 8] -TAIL_BYTES = 2048 - - -def _resolve_watch_block( - want_watch: bool, - overrides: dict | None, - prior_watch: dict | None, -) -> dict | None: - """Decide what watch block a (re)starting proc should have. - - Three paths, in priority order: - 1. want_watch=True -> fresh default block, optionally with overrides - 2. prior_watch enabled (internal restart path) -> carry forward, - clear runtime fields (watcher_pid, errored, error_reason, - last_stderr_tail) but PRESERVE restart counters - 3. otherwise -> None (no watch) - - Pure: no side effects. Returns a new dict in all non-None cases. - """ - if want_watch: - return _default_watch_config(overrides) - if prior_watch and prior_watch.get("enabled"): - carried = dict(prior_watch) - carried["watcher_pid"] = None - carried["watcher_pgid"] = None - carried["errored"] = False - carried["error_reason"] = None - carried["last_stderr_tail"] = None - return carried - return None - - -def _default_watch_config(overrides: dict | None = None) -> dict: - """Build a fresh watch config block with defaults.""" - cfg = { - "enabled": True, - "interval": WATCH_DEFAULTS["interval"], - "min_uptime": WATCH_DEFAULTS["min_uptime"], - "on_fast_crash": WATCH_DEFAULTS["on_fast_crash"], - "watcher_pid": None, - "watcher_pgid": None, - "restarts": 0, - "last_restart_at": None, - "errored": False, - "error_reason": None, - "last_stderr_tail": None, - } - if overrides: - for k, v in overrides.items(): - if v is not None and k in cfg: - cfg[k] = v - return cfg - - -def _spawn_watcher(name: str) -> tuple[int | None, int | None]: - """Detach a watcher process for `name`. Returns (pid, pgid).""" - try: - wlog = open(watcher_log_path(name), "a") - proc = subprocess.Popen( - [sys.executable, os.path.abspath(__file__), "__watcher__", name], - stdout=wlog, - stderr=wlog, - stdin=subprocess.DEVNULL, - start_new_session=True, - ) - wlog.close() - except Exception as e: - watcher_log(name, f"failed to spawn watcher: {e}") - return None, None - try: - pgid = os.getpgid(proc.pid) - except OSError: - pgid = None - return proc.pid, pgid - - -def _kill_watcher(info: dict) -> None: - """Kill the watcher associated with `info` (if any), in-place clears pids.""" - w = info.get("watch") or {} - wpid = w.get("watcher_pid") - wpgid = w.get("watcher_pgid") - if wpid and is_running(wpid): - kill_process(wpid, wpgid) - if "watch" in info: - info["watch"]["watcher_pid"] = None - info["watch"]["watcher_pgid"] = None - - -def _tail_stderr(name: str, nbytes: int = TAIL_BYTES) -> str: - """Return last nbytes of stderr log, stripped.""" - lf = log_path(name, "err") - if not lf.exists(): - return "" - try: - with open(lf, "rb") as f: - f.seek(0, os.SEEK_END) - size = f.tell() - f.seek(max(0, size - nbytes)) - data = f.read().decode("utf-8", errors="replace") - # strip leading partial line - if size > nbytes and "\n" in data: - data = data[data.index("\n") + 1:] - return data.strip() - except OSError: - return "" - - -def _restart_proc_inplace(info: dict) -> tuple[int | None, int | None, str | None]: - """Spawn target command again, return (pid, pgid, err_msg).""" - name = info["name"] - command = info["command"] - cwd = info.get("cwd") or os.getcwd() - stdout_log = log_path(name, "out") - stderr_log = log_path(name, "err") - try: - out_f = open(stdout_log, "a") - err_f = open(stderr_log, "a") - ts = datetime.now().isoformat() - marker = f"\n=== [{ts}] [watch restart] {' '.join(command)} ===\n" - out_f.write(marker) - err_f.write(marker) - out_f.flush() - err_f.flush() - proc = subprocess.Popen( - command, - stdout=out_f, - stderr=err_f, - stdin=subprocess.DEVNULL, - start_new_session=True, - cwd=cwd, - ) - out_f.close() - err_f.close() - except FileNotFoundError: - return None, None, f"command not found: {command[0]}" - except PermissionError: - return None, None, f"permission denied: {command[0]}" - except Exception as e: - return None, None, f"failed to start: {e}" - try: - pgid = os.getpgid(proc.pid) - except OSError: - pgid = None - return proc.pid, pgid, None - - -def cmd_watcher_loop(name: str) -> int: - """Internal: watcher loop entry point. Invoked as `bgo __watcher__ `.""" - # Auto-reap children so dead procs disappear instead of lingering as zombies - try: - signal.signal(signal.SIGCHLD, signal.SIG_IGN) - except (ValueError, OSError): - pass - - info = load_proc(name) - if not info or not info.get("watch", {}).get("enabled"): - return 0 - - watcher_log(name, f"watcher started for pid={info.get('pid')}") - backoff_idx = 0 - current_started_at = info.get("started_at") - needs_early_check = True - - def _start_epoch() -> float: - try: - s = current_started_at.replace("Z", "+00:00") - return datetime.fromisoformat(s).timestamp() - except Exception: - return time.time() - - while True: - interval = info.get("watch", {}).get("interval", WATCH_DEFAULTS["interval"]) - min_uptime_cfg = info.get("watch", {}).get("min_uptime", WATCH_DEFAULTS["min_uptime"]) - - if needs_early_check: - # High-frequency poll during the min_uptime window so fast-crashes - # are caught with accurate short uptime readings (even when the - # routine poll interval is larger than min_uptime). - deadline = _start_epoch() + min_uptime_cfg - died_early = False - while time.time() < deadline: - time.sleep(0.2) - cur_pid = info.get("pid") - if not is_running(cur_pid): - died_early = True - break - needs_early_check = False - if not died_early: - # Sleep the remainder of the normal interval - remaining = max(0.0, (_start_epoch() + min_uptime_cfg + interval) - time.time()) - if remaining > 0: - time.sleep(min(remaining, interval)) - else: - time.sleep(max(1, interval)) - - info = load_proc(name) - if not info: - watcher_log(name, "proc state vanished; exiting") - return 0 - w = info.get("watch") or {} - if not w.get("enabled"): - watcher_log(name, "watch disabled; exiting") - return 0 - if info.get("status") == "stopped": - watcher_log(name, "proc manually stopped; exiting") - return 0 - - pid = info.get("pid") - if is_running(pid): - continue - - # Process died. Compute uptime. - try: - started = datetime.fromisoformat(current_started_at.replace("Z", "+00:00")) - uptime = (datetime.now(timezone.utc) - started).total_seconds() - except Exception: - uptime = 0.0 - - min_uptime = w.get("min_uptime", WATCH_DEFAULTS["min_uptime"]) - mode = w.get("on_fast_crash", WATCH_DEFAULTS["on_fast_crash"]) - fast = uptime < min_uptime - - if fast: - tail = _tail_stderr(name) - watcher_log(name, f"fast-crash: uptime={uptime:.2f}s mode={mode}") - - if mode == "stop": - reason = f"fast-crash (uptime {uptime:.2f}s, mode=stop)" - info["watch"]["errored"] = True - info["watch"]["error_reason"] = reason - info["watch"]["last_stderr_tail"] = tail - info["watch"]["watcher_pid"] = None - info["watch"]["watcher_pgid"] = None - info["status"] = "stopped" - save_proc(name, info) - watcher_log(name, "errored; exiting") - _notify_errored(name, reason) - return 0 - - if mode == "backoff": - if backoff_idx >= len(BACKOFF_SCHEDULE): - reason = f"{len(BACKOFF_SCHEDULE) + 1} consecutive fast-crashes" - info["watch"]["errored"] = True - info["watch"]["error_reason"] = reason - info["watch"]["last_stderr_tail"] = tail - info["watch"]["watcher_pid"] = None - info["watch"]["watcher_pgid"] = None - info["status"] = "stopped" - save_proc(name, info) - watcher_log(name, "backoff exhausted; errored; exiting") - _notify_errored(name, reason) - return 0 - wait = BACKOFF_SCHEDULE[backoff_idx] - watcher_log(name, f"backoff sleep {wait}s (step {backoff_idx + 1}/{len(BACKOFF_SCHEDULE)})") - time.sleep(wait) - backoff_idx += 1 - elif mode == "retry": - wait = BACKOFF_SCHEDULE[min(backoff_idx, len(BACKOFF_SCHEDULE) - 1)] - watcher_log(name, f"retry mode: sleep {wait}s") - time.sleep(wait) - backoff_idx = min(backoff_idx + 1, len(BACKOFF_SCHEDULE) - 1) - else: - backoff_idx = 0 - - # Reload (state may have shifted during sleep) - info = load_proc(name) - if not info or info.get("status") == "stopped" or not info.get("watch", {}).get("enabled"): - watcher_log(name, "state changed during backoff; exiting") - return 0 - - new_pid, new_pgid, err = _restart_proc_inplace(info) - if err: - watcher_log(name, f"restart failed: {err}") - info["watch"]["errored"] = True - info["watch"]["error_reason"] = err - info["watch"]["last_stderr_tail"] = _tail_stderr(name) - info["watch"]["watcher_pid"] = None - info["watch"]["watcher_pgid"] = None - info["status"] = "stopped" - save_proc(name, info) - _notify_errored(name, err) - return 0 - - now_iso = datetime.now(timezone.utc).isoformat() - info["pid"] = new_pid - info["pgid"] = new_pgid - info["started_at"] = now_iso - info["status"] = "running" - info["watch"]["restarts"] = info["watch"].get("restarts", 0) + 1 - info["watch"]["last_restart_at"] = now_iso - save_proc(name, info) - current_started_at = now_iso - needs_early_check = True - watcher_log(name, f"restart #{info['watch']['restarts']} pid={new_pid} (prev uptime {uptime:.2f}s)") +# Proc state files + log paths live in bgo_cli._state. Re-export so the +# script's own namespace keeps the historical bindings; the test fixture +# also patches the source module to keep them in sync. +from bgo_cli._state import ( # noqa: E402 + BGO_DIR, + PROCS_DIR, + LOGS_DIR, + init_dirs, + proc_file, + log_path, + watcher_log_path, + watcher_log, + load_proc, + save_proc, + delete_proc, + load_all_procs, +) + +# Terminal capability detection, ANSI colors, and glyph sets all live +# in bgo_cli._term — see that module for the resolution cascade. We +# re-bind every public symbol into the script's namespace so the test +# fixture (which loads this file as a module) and any external scripts +# importing from the bare ``bgo`` path keep working unchanged. +from bgo_cli._term import ( # noqa: E402 — module-level import after constants is fine + COLORS, + ANSI_RE, + color, + strip_ansi, + truncate, + LEVEL_PLAIN, + LEVEL_NORMAL, + LEVEL_FANCY, + _detect_table_level, + GLYPHS, + glyphs, +) + + +# Process inspection + lifecycle helpers live in bgo_cli._proc. +from bgo_cli._proc import ( # noqa: E402 + _is_zombie, + is_running, + _BLANK_PINFO, + get_process_info, + get_process_info_batch, + _looks_like_command, + derive_name, + resolve_command, + kill_process, +) + + +# Watcher loop + watch-config helpers live in bgo_cli._watcher. +from bgo_cli._watcher import ( # noqa: E402 + WATCH_DEFAULTS, + BACKOFF_SCHEDULE, + TAIL_BYTES, + _notify_errored, + _resolve_watch_block, + _default_watch_config, + _spawn_watcher, + _kill_watcher, + _tail_stderr, + _restart_proc_inplace, + cmd_watcher_loop, +) # --- Commands --- diff --git a/src/bgo_cli/_proc.py b/src/bgo_cli/_proc.py new file mode 100644 index 0000000..f180e0b --- /dev/null +++ b/src/bgo_cli/_proc.py @@ -0,0 +1,191 @@ +"""Process inspection and lifecycle utilities. + +Cross-platform helpers for: + +* Detecting whether a PID is alive AND not a zombie (Linux reads + ``/proc//stat``; macOS shells out to ``ps -o stat=``). +* Pulling CPU / memory / uptime in one batched ``ps`` call regardless + of how many PIDs need querying. +* Sending SIGTERM / SIGKILL with a wait-and-escalate fallback to a + process group, so children spawned via ``start_new_session=True`` + are reliably reaped. +* Light command-shape helpers (``derive_name``, ``resolve_command``, + ``_looks_like_command``) used by the start path. + +Imports from ``_term`` for the one error message that needs color. +""" + +from __future__ import annotations + +import os +import shutil +import signal +import subprocess +import sys +import time + +from bgo_cli._term import color + + +def _is_zombie(pid: int) -> bool: + """Return True if pid is a zombie/defunct process. Platform-aware.""" + if sys.platform.startswith("linux"): + try: + with open(f"/proc/{pid}/stat", "r") as f: + stat = f.read() + # stat format: pid (comm) state ... comm may contain + # spaces/parens; rfind ')' anchors us past the comm field. + rparen = stat.rfind(")") + if rparen != -1: + state = stat[rparen + 2 : rparen + 3] + return state == "Z" + except (OSError, IndexError): + return False + return False + if sys.platform == "darwin": + try: + result = subprocess.run( + ["ps", "-p", str(pid), "-o", "stat=", ], + capture_output=True, text=True, timeout=2, + ) + if result.returncode == 0: + state = result.stdout.strip() + # macOS ps stat: 'Z' is zombie; may be prefixed with flags + return state.startswith("Z") + except (subprocess.SubprocessError, OSError): + return False + return False + + +def is_running(pid: int | None) -> bool: + """Return True if pid is alive AND not a zombie/defunct process.""" + if pid is None: + return False + try: + os.kill(pid, 0) + except (ProcessLookupError, OSError): + return False + return not _is_zombie(pid) + + +_BLANK_PINFO: dict[str, str] = {"cpu": "-", "mem": "-", "time": "-"} + + +def get_process_info(pid: int) -> dict: + """Get CPU / MEM / uptime for a single pid via ps. Prefer batch.""" + return get_process_info_batch([pid]).get(pid, dict(_BLANK_PINFO)) + + +def get_process_info_batch(pids: list[int]) -> dict[int, dict]: + """Single ps call for many pids. Returns ``{pid: {cpu, mem, time}}``.""" + result_map: dict[int, dict] = {} + if not pids: + return result_map + # ps -p accepts comma-separated pids on both Linux and macOS. + pid_arg = ",".join(str(p) for p in pids) + try: + result = subprocess.run( + ["ps", "-p", pid_arg, "-o", "pid,%cpu,%mem,etime", "--no-headers"], + capture_output=True, + text=True, + timeout=4, + ) + if result.returncode == 0: + for line in result.stdout.splitlines(): + parts = line.split(None, 3) + if len(parts) >= 4: + try: + result_map[int(parts[0])] = { + "cpu": parts[1], "mem": parts[2], "time": parts[3], + } + except ValueError: + continue + except (subprocess.SubprocessError, OSError): + pass + # Fill misses with blanks so callers don't have to defensively .get() + for p in pids: + result_map.setdefault(p, dict(_BLANK_PINFO)) + return result_map + + +def _looks_like_command(arg: str) -> bool: + """Return True if ``arg`` looks like an executable, not a plain name. + + A path separator, a dot (extension), or a hit on ``shutil.which`` + each qualify. Used by direct-mode parsing to decide whether the + first positional is a name or the start of the command. + """ + if os.sep in arg or arg.startswith("./"): + return True + if "." in arg: + return True + if shutil.which(arg): + return True + return False + + +def derive_name(cmd: list[str]) -> str: + """Derive a process name from the command (basename minus extension).""" + base = os.path.basename(cmd[0]) + for ext in (".py", ".sh", ".js", ".ts", ".rb", ".pl", ".exe"): + if base.endswith(ext): + base = base[: -len(ext)] + return base + + +def resolve_command(cmd: list[str]) -> list[str]: + """Resolve ``cmd[0]`` to its full path via ``shutil.which`` if possible.""" + binary = shutil.which(cmd[0]) + if binary: + cmd = cmd[:] + cmd[0] = binary + return cmd + + +def kill_process(pid: int, pgid: int | None, force: bool = False) -> bool: + """Kill a process (and its process group). Returns True if dead. + + Sends SIGTERM (or SIGKILL with ``force=True``), waits up to 5 + seconds, then escalates to SIGKILL once if the gentler signal + didn't take. Returning False means the process survived both + rounds (probably because we lack permission). + """ + sig = signal.SIGKILL if force else signal.SIGTERM + + try: + if pgid: + os.killpg(pgid, sig) + else: + os.kill(pid, sig) + except ProcessLookupError: + return True + except PermissionError: + print(f"{color('red', '❌')} Permission denied killing PID {pid}") + return False + + # Wait for termination (5 seconds in 0.1s slices). + for _ in range(50): + if not is_running(pid): + return True + time.sleep(0.1) + + # Escalate to SIGKILL if SIGTERM didn't work. + if not force and is_running(pid): + try: + if pgid: + os.killpg(pgid, signal.SIGKILL) + else: + os.kill(pid, signal.SIGKILL) + time.sleep(0.3) + except (ProcessLookupError, PermissionError): + pass + + return not is_running(pid) + + +__all__ = [ + "_is_zombie", "is_running", + "get_process_info", "get_process_info_batch", + "_looks_like_command", "derive_name", "resolve_command", + "kill_process", +] diff --git a/src/bgo_cli/_state.py b/src/bgo_cli/_state.py new file mode 100644 index 0000000..08abf2e --- /dev/null +++ b/src/bgo_cli/_state.py @@ -0,0 +1,113 @@ +"""Proc state files: load, save, delete, and directory layout. + +bgo stores one JSON file per process under ``~/.bgo/procs/.json`` +and three log files per process under ``~/.bgo/logs/``. Writes go +through :func:`save_proc`, which is atomic via tmp + ``os.replace`` +so a torn JSON never lands on disk even if the watcher and the CLI +race. + +The module-level paths (``BGO_DIR``, ``PROCS_DIR``, ``LOGS_DIR``) are +mutable so the test fixture can redirect them into a sandbox before +calling :func:`init_dirs`. + +No dependencies on other ``bgo_cli`` modules. +""" + +from __future__ import annotations + +import json +import os +from datetime import datetime +from pathlib import Path + +# Default storage layout. Tests override these by monkeypatching the +# module attributes before calling init_dirs(). +BGO_DIR = Path.home() / ".bgo" +PROCS_DIR = BGO_DIR / "procs" +LOGS_DIR = BGO_DIR / "logs" + + +def init_dirs() -> None: + """Create the per-user state and log directories if missing.""" + PROCS_DIR.mkdir(parents=True, exist_ok=True) + LOGS_DIR.mkdir(parents=True, exist_ok=True) + + +def proc_file(name: str) -> Path: + """Path of the JSON state file for ``name``.""" + return PROCS_DIR / f"{name}.json" + + +def log_path(name: str, stream: str = "out") -> Path: + """Path of the stdout / stderr log file for ``name``.""" + return LOGS_DIR / f"{name}.{stream}.log" + + +def watcher_log_path(name: str) -> Path: + """Path of the watcher event log file for ``name``.""" + return LOGS_DIR / f"{name}.watcher.log" + + +def watcher_log(name: str, msg: str) -> None: + """Append one timestamped line to the watcher log. Never raises.""" + try: + ts = datetime.now().isoformat(timespec="seconds") + with open(watcher_log_path(name), "a") as f: + f.write(f"[{ts}] {msg}\n") + except OSError: + pass + + +def load_proc(name: str) -> dict | None: + """Return parsed state for ``name``, or ``None`` if missing/corrupt.""" + pf = proc_file(name) + if not pf.exists(): + return None + try: + return json.loads(pf.read_text()) + except (json.JSONDecodeError, OSError): + return None + + +def save_proc(name: str, info: dict) -> None: + """Atomically write ``info`` as ``name``'s state file. + + Uses a tmp file plus ``os.replace`` so concurrent writers (CLI + + watcher) cannot land a torn JSON. ``os.replace`` is atomic on + POSIX when src and dst share a filesystem, which is true here + (both under ``~/.bgo/procs/``). + """ + pf = proc_file(name) + tmp = pf.with_suffix(pf.suffix + ".tmp") + tmp.write_text(json.dumps(info, indent=2)) + os.replace(tmp, pf) + + +def delete_proc(name: str, keep_logs: bool = False) -> None: + """Remove ``name``'s state file. Drops logs unless ``keep_logs``.""" + proc_file(name).unlink(missing_ok=True) + if keep_logs: + return + for stream in ("out", "err"): + log_path(name, stream).unlink(missing_ok=True) + watcher_log_path(name).unlink(missing_ok=True) + + +def load_all_procs() -> dict[str, dict]: + """Return ``{name: info}`` for every state file under PROCS_DIR.""" + procs: dict[str, dict] = {} + for pf in sorted(PROCS_DIR.glob("*.json")): + try: + info = json.loads(pf.read_text()) + procs[info.get("name", pf.stem)] = info + except (json.JSONDecodeError, OSError): + continue + return procs + + +__all__ = [ + "BGO_DIR", "PROCS_DIR", "LOGS_DIR", + "init_dirs", + "proc_file", "log_path", "watcher_log_path", "watcher_log", + "load_proc", "save_proc", "delete_proc", "load_all_procs", +] diff --git a/src/bgo_cli/_term.py b/src/bgo_cli/_term.py new file mode 100644 index 0000000..a56fa43 --- /dev/null +++ b/src/bgo_cli/_term.py @@ -0,0 +1,145 @@ +"""Terminal capability detection, ANSI colors, and glyph sets. + +bgo renders output at three increasing levels of polish: + +* ``plain`` — no color, ASCII-only dashes, no glyphs. Used for CI + logs, ``TERM=dumb``, non-TTY pipes, or explicit ``--plain``. +* ``normal`` — ANSI color + ASCII rules. Default for color-capable + TTYs without UTF-8. +* ``fancy`` — ANSI color + Unicode box-drawing. Default for + UTF-8-capable TTYs. + +The detection cascade lives in :func:`_detect_table_level`. Public +helpers :func:`color`, :func:`strip_ansi`, :func:`truncate`, and +:func:`glyphs` are imported by every renderer in the codebase. + +This module has no dependencies on other ``bgo_cli`` modules — it +sits at the bottom of the import graph. +""" + +from __future__ import annotations + +import os +import re +import sys + +# ANSI color escape codes. Looked up by name; unknown names yield no +# wrapping so callers can pass user-supplied keys without crashing. +COLORS: dict[str, str] = { + "green": "\033[32m", + "red": "\033[31m", + "yellow": "\033[33m", + "blue": "\033[34m", + "gray": "\033[90m", + "reset": "\033[0m", + "bold": "\033[1m", +} + +ANSI_RE = re.compile(r"\033\[[0-9;]*m") + + +def color(name: str, text: str) -> str: + """Wrap text in color codes, only if stdout is a TTY.""" + if not sys.stdout.isatty(): + return str(text) + return f"{COLORS.get(name, '')}{text}{COLORS['reset']}" + + +def strip_ansi(s: str) -> str: + """Remove ANSI escape codes from a string.""" + return ANSI_RE.sub("", s) + + +def truncate(s: str, width: int) -> str: + """Truncate string to fit in width, accounting for ANSI codes.""" + plain = strip_ansi(s) + if len(plain) > width: + return s[: width - 3] + "..." + return s + + +# --- Terminal capability detection -------------------------------------- + +LEVEL_PLAIN = "plain" +LEVEL_NORMAL = "normal" +LEVEL_FANCY = "fancy" + + +def _detect_table_level(force: str | None = None) -> str: + """Decide which rendering level to use. + + Resolution order: + 1. explicit ``force`` (from ``--plain`` / ``--fancy`` or + ``$BGO_TABLE``) + 2. non-TTY stdout -> plain + 3. ``$TERM=dumb`` -> plain + 4. ``$LANG`` / ``$LC_*`` lacks UTF-8 -> normal + 5. otherwise -> fancy + """ + if force in (LEVEL_PLAIN, LEVEL_NORMAL, LEVEL_FANCY): + return force + env_force = os.environ.get("BGO_TABLE", "").strip().lower() + if env_force in (LEVEL_PLAIN, LEVEL_NORMAL, LEVEL_FANCY): + return env_force + if not sys.stdout.isatty(): + return LEVEL_PLAIN + if os.environ.get("TERM", "") == "dumb": + return LEVEL_PLAIN + lc = ( + os.environ.get("LC_ALL") + or os.environ.get("LC_CTYPE") + or os.environ.get("LANG") + or "" + ).upper() + if "UTF-8" not in lc and "UTF8" not in lc: + return LEVEL_NORMAL + return LEVEL_FANCY + + +# Glyph set per level. Keys must be identical across levels so callers +# can index without branching. +GLYPHS: dict[str, dict[str, str]] = { + LEVEL_PLAIN: { + "hline": "-", "vline": "|", "cross": "+", + "tl": "+", "tr": "+", "bl": "+", "br": "+", + "tdown": "+", "tup": "+", "tleft": "+", "tright": "+", + "online": "ON", "stopped": "OFF", + "watching": "[W]", "errored": "[!]", "watcher_dead": "[?]", + "watch_none": "-", + "ok": "OK", "fail": "FAIL", "warn": "WARN", + "tombstone": "[X]", "rocket": "[+]", "eye": "[W]", + }, + LEVEL_NORMAL: { + "hline": "─", "vline": "│", "cross": "┼", + "tl": "┌", "tr": "┐", "bl": "└", "br": "┘", + "tdown": "┬", "tup": "┴", "tleft": "┤", "tright": "├", + "online": "online", "stopped": "stopped", + "watching": "✓", "errored": "⚠", "watcher_dead": "!", + "watch_none": "-", + "ok": "✅", "fail": "❌", "warn": "⚠️", + "tombstone": "🗑️", "rocket": "🚀", "eye": "👁", + }, + LEVEL_FANCY: { + "hline": "━", "vline": "┃", "cross": "╋", + "tl": "┏", "tr": "┓", "bl": "┗", "br": "┛", + "tdown": "┳", "tup": "┻", "tleft": "┫", "tright": "┣", + "online": "● online", "stopped": "○ stopped", + "watching": "✓", "errored": "⚠", "watcher_dead": "!", + "watch_none": "·", + "ok": "✅", "fail": "❌", "warn": "⚠️", + "tombstone": "🗑️", "rocket": "🚀", "eye": "👁", + }, +} + + +def glyphs(level: str | None = None) -> dict[str, str]: + """Return the glyph set for a level (default: auto-detect).""" + return GLYPHS[level or _detect_table_level()] + + +__all__ = [ + "COLORS", "ANSI_RE", + "color", "strip_ansi", "truncate", + "LEVEL_PLAIN", "LEVEL_NORMAL", "LEVEL_FANCY", + "_detect_table_level", "GLYPHS", "glyphs", +] diff --git a/src/bgo_cli/_watcher.py b/src/bgo_cli/_watcher.py new file mode 100644 index 0000000..298ffd3 --- /dev/null +++ b/src/bgo_cli/_watcher.py @@ -0,0 +1,410 @@ +"""Watcher process: auto-restart on crash with fast-crash backoff. + +Each ``bgo start -w …`` spawns a *watcher* — a detached sidecar that +polls the target PID and decides what to do when it dies: + +* ``backoff`` (default): wait 2s, then 4s, then 8s between retries. + After 4 consecutive fast-crashes (sub-``min_uptime`` lifetime), + mark the proc ``errored`` and exit. +* ``stop``: any fast-crash transitions straight to ``errored``. +* ``retry``: keep restarting forever, capped at 8s backoff. + +On errored transitions, a best-effort desktop notification is fired +via :func:`bgo_cli._notify.notify`. Watch-config helpers +(:func:`_resolve_watch_block`, :func:`_default_watch_config`) are +pure functions used by the start path. + +Depends on :mod:`bgo_cli._state`, :mod:`bgo_cli._proc`, and the +optional :mod:`bgo_cli._notify`. Avoids :mod:`_term` because the +watcher runs detached with no TTY. +""" + +from __future__ import annotations + +import os +import shutil +import signal +import subprocess +import sys +import time +from datetime import datetime, timezone + +from bgo_cli._proc import is_running, kill_process +from bgo_cli._state import ( + load_proc, + log_path, + save_proc, + watcher_log, + watcher_log_path, +) + +# Defaults for a fresh watch block. ``WATCH_DEFAULTS`` is consulted at +# many sites; keeping it module-level mirrors how the script-era code +# treated it. +WATCH_DEFAULTS: dict[str, object] = { + "interval": 3, + "min_uptime": 2, + "on_fast_crash": "backoff", # backoff | stop | retry +} +BACKOFF_SCHEDULE: list[int] = [2, 4, 8] +TAIL_BYTES: int = 2048 + + +def _notify_errored(name: str, reason: str) -> None: + """Best-effort desktop notification on an errored transition. + + Lazy-imports :mod:`bgo_cli._notify`; any failure (including a + missing module in stripped builds) is swallowed — notifications + must never break the watcher loop. + """ + try: + from bgo_cli._notify import notify + except ImportError: + return + try: + notify(f"bgo: {name} errored", reason, level="error") + except Exception: + pass + + +def _resolve_watch_block( + want_watch: bool, + overrides: dict | None, + prior_watch: dict | None, +) -> dict | None: + """Decide what watch block a (re)starting proc should have. + + Three paths, in priority order: + + 1. ``want_watch=True`` -> fresh default block, optionally with overrides. + 2. ``prior_watch`` enabled (internal restart path) -> carry forward, + clear runtime fields (watcher_pid, errored, error_reason, + last_stderr_tail) but PRESERVE restart counters. + 3. otherwise -> ``None`` (no watch). + + Pure: no side effects. Returns a new dict in all non-None cases. + """ + if want_watch: + return _default_watch_config(overrides) + if prior_watch and prior_watch.get("enabled"): + carried = dict(prior_watch) + carried["watcher_pid"] = None + carried["watcher_pgid"] = None + carried["errored"] = False + carried["error_reason"] = None + carried["last_stderr_tail"] = None + return carried + return None + + +def _default_watch_config(overrides: dict | None = None) -> dict: + """Build a fresh watch config block with defaults applied.""" + cfg: dict = { + "enabled": True, + "interval": WATCH_DEFAULTS["interval"], + "min_uptime": WATCH_DEFAULTS["min_uptime"], + "on_fast_crash": WATCH_DEFAULTS["on_fast_crash"], + "watcher_pid": None, + "watcher_pgid": None, + "restarts": 0, + "last_restart_at": None, + "errored": False, + "error_reason": None, + "last_stderr_tail": None, + } + if overrides: + for k, v in overrides.items(): + if v is not None and k in cfg: + cfg[k] = v + return cfg + + +def _bgo_entrypoint() -> str: + """Resolve the ``bgo`` binary path for re-invoking the watcher loop. + + Prefer ``shutil.which("bgo")`` so we hit the installed entrypoint + (uv tool / pipx / pip's user site). Fall back to the script path + via ``sys.argv[0]`` resolved through :func:`os.path.realpath`, + which is correct when running ``./bgo`` from the repo. + """ + found = shutil.which("bgo") + if found: + return found + return os.path.realpath(sys.argv[0]) if sys.argv else "" + + +def _spawn_watcher(name: str) -> tuple[int | None, int | None]: + """Detach a watcher subprocess for ``name``. Returns ``(pid, pgid)``.""" + try: + wlog = open(watcher_log_path(name), "a") + bgo_bin = _bgo_entrypoint() + proc = subprocess.Popen( + [sys.executable, bgo_bin, "__watcher__", name], + stdout=wlog, + stderr=wlog, + stdin=subprocess.DEVNULL, + start_new_session=True, + ) + wlog.close() + except Exception as e: + watcher_log(name, f"failed to spawn watcher: {e}") + return None, None + try: + pgid = os.getpgid(proc.pid) + except OSError: + pgid = None + return proc.pid, pgid + + +def _kill_watcher(info: dict) -> None: + """Kill the watcher associated with ``info`` (if any). + + Mutates ``info["watch"]`` in place to clear the recorded pids so + the caller's subsequent ``save_proc`` reflects the kill. + """ + w = info.get("watch") or {} + wpid = w.get("watcher_pid") + wpgid = w.get("watcher_pgid") + if wpid and is_running(wpid): + kill_process(wpid, wpgid) + if "watch" in info: + info["watch"]["watcher_pid"] = None + info["watch"]["watcher_pgid"] = None + + +def _tail_stderr(name: str, nbytes: int = TAIL_BYTES) -> str: + """Return the last ``nbytes`` of the stderr log, stripped.""" + lf = log_path(name, "err") + if not lf.exists(): + return "" + try: + with open(lf, "rb") as f: + f.seek(0, os.SEEK_END) + size = f.tell() + f.seek(max(0, size - nbytes)) + data = f.read().decode("utf-8", errors="replace") + # Drop the leading partial line so the tail starts cleanly. + if size > nbytes and "\n" in data: + data = data[data.index("\n") + 1:] + return data.strip() + except OSError: + return "" + + +def _restart_proc_inplace(info: dict) -> tuple[int | None, int | None, str | None]: + """Spawn ``info["command"]`` again. Returns ``(pid, pgid, err_msg)``.""" + name = info["name"] + command = info["command"] + cwd = info.get("cwd") or os.getcwd() + stdout_log = log_path(name, "out") + stderr_log = log_path(name, "err") + try: + out_f = open(stdout_log, "a") + err_f = open(stderr_log, "a") + ts = datetime.now().isoformat() + marker = f"\n=== [{ts}] [watch restart] {' '.join(command)} ===\n" + out_f.write(marker) + err_f.write(marker) + out_f.flush() + err_f.flush() + proc = subprocess.Popen( + command, + stdout=out_f, + stderr=err_f, + stdin=subprocess.DEVNULL, + start_new_session=True, + cwd=cwd, + ) + out_f.close() + err_f.close() + except FileNotFoundError: + return None, None, f"command not found: {command[0]}" + except PermissionError: + return None, None, f"permission denied: {command[0]}" + except Exception as e: + return None, None, f"failed to start: {e}" + try: + pgid = os.getpgid(proc.pid) + except OSError: + pgid = None + return proc.pid, pgid, None + + +def cmd_watcher_loop(name: str) -> int: + """Watcher entry point. Invoked as ``bgo __watcher__ ``.""" + # Auto-reap children so dead procs disappear instead of lingering + # as zombies in our process group. + try: + signal.signal(signal.SIGCHLD, signal.SIG_IGN) + except (ValueError, OSError): + pass + + info = load_proc(name) + if not info or not info.get("watch", {}).get("enabled"): + return 0 + + watcher_log(name, f"watcher started for pid={info.get('pid')}") + backoff_idx = 0 + current_started_at = info.get("started_at") + needs_early_check = True + + def _start_epoch() -> float: + try: + s = current_started_at.replace("Z", "+00:00") + return datetime.fromisoformat(s).timestamp() + except Exception: + return time.time() + + while True: + interval = info.get("watch", {}).get("interval", WATCH_DEFAULTS["interval"]) + min_uptime_cfg = info.get("watch", {}).get( + "min_uptime", WATCH_DEFAULTS["min_uptime"] + ) + + if needs_early_check: + # High-frequency poll during the min_uptime window so + # fast-crashes are caught with accurate short uptime + # readings (even when the routine poll interval is larger + # than min_uptime). + deadline = _start_epoch() + min_uptime_cfg + died_early = False + while time.time() < deadline: + time.sleep(0.2) + cur_pid = info.get("pid") + if not is_running(cur_pid): + died_early = True + break + needs_early_check = False + if not died_early: + # Sleep the remainder of the normal interval + remaining = max( + 0.0, + (_start_epoch() + min_uptime_cfg + interval) - time.time(), + ) + if remaining > 0: + time.sleep(min(remaining, interval)) + else: + time.sleep(max(1, interval)) + + info = load_proc(name) + if not info: + watcher_log(name, "proc state vanished; exiting") + return 0 + w = info.get("watch") or {} + if not w.get("enabled"): + watcher_log(name, "watch disabled; exiting") + return 0 + if info.get("status") == "stopped": + watcher_log(name, "proc manually stopped; exiting") + return 0 + + pid = info.get("pid") + if is_running(pid): + continue + + # Process died. Compute uptime. + try: + started = datetime.fromisoformat(current_started_at.replace("Z", "+00:00")) + uptime = (datetime.now(timezone.utc) - started).total_seconds() + except Exception: + uptime = 0.0 + + min_uptime = w.get("min_uptime", WATCH_DEFAULTS["min_uptime"]) + mode = w.get("on_fast_crash", WATCH_DEFAULTS["on_fast_crash"]) + fast = uptime < min_uptime + + if fast: + tail = _tail_stderr(name) + watcher_log(name, f"fast-crash: uptime={uptime:.2f}s mode={mode}") + + if mode == "stop": + reason = f"fast-crash (uptime {uptime:.2f}s, mode=stop)" + info["watch"]["errored"] = True + info["watch"]["error_reason"] = reason + info["watch"]["last_stderr_tail"] = tail + info["watch"]["watcher_pid"] = None + info["watch"]["watcher_pgid"] = None + info["status"] = "stopped" + save_proc(name, info) + watcher_log(name, "errored; exiting") + _notify_errored(name, reason) + return 0 + + if mode == "backoff": + if backoff_idx >= len(BACKOFF_SCHEDULE): + reason = ( + f"{len(BACKOFF_SCHEDULE) + 1} consecutive fast-crashes" + ) + info["watch"]["errored"] = True + info["watch"]["error_reason"] = reason + info["watch"]["last_stderr_tail"] = tail + info["watch"]["watcher_pid"] = None + info["watch"]["watcher_pgid"] = None + info["status"] = "stopped" + save_proc(name, info) + watcher_log(name, "backoff exhausted; errored; exiting") + _notify_errored(name, reason) + return 0 + wait = BACKOFF_SCHEDULE[backoff_idx] + watcher_log( + name, + f"backoff sleep {wait}s " + f"(step {backoff_idx + 1}/{len(BACKOFF_SCHEDULE)})", + ) + time.sleep(wait) + backoff_idx += 1 + elif mode == "retry": + wait = BACKOFF_SCHEDULE[min(backoff_idx, len(BACKOFF_SCHEDULE) - 1)] + watcher_log(name, f"retry mode: sleep {wait}s") + time.sleep(wait) + backoff_idx = min(backoff_idx + 1, len(BACKOFF_SCHEDULE) - 1) + else: + backoff_idx = 0 + + # Reload (state may have shifted during sleep). + info = load_proc(name) + if ( + not info + or info.get("status") == "stopped" + or not info.get("watch", {}).get("enabled") + ): + watcher_log(name, "state changed during backoff; exiting") + return 0 + + new_pid, new_pgid, err = _restart_proc_inplace(info) + if err: + watcher_log(name, f"restart failed: {err}") + info["watch"]["errored"] = True + info["watch"]["error_reason"] = err + info["watch"]["last_stderr_tail"] = _tail_stderr(name) + info["watch"]["watcher_pid"] = None + info["watch"]["watcher_pgid"] = None + info["status"] = "stopped" + save_proc(name, info) + _notify_errored(name, err) + return 0 + + now_iso = datetime.now(timezone.utc).isoformat() + info["pid"] = new_pid + info["pgid"] = new_pgid + info["started_at"] = now_iso + info["status"] = "running" + info["watch"]["restarts"] = info["watch"].get("restarts", 0) + 1 + info["watch"]["last_restart_at"] = now_iso + save_proc(name, info) + current_started_at = now_iso + needs_early_check = True + watcher_log( + name, + f"restart #{info['watch']['restarts']} pid={new_pid} " + f"(prev uptime {uptime:.2f}s)", + ) + + +__all__ = [ + "WATCH_DEFAULTS", "BACKOFF_SCHEDULE", "TAIL_BYTES", + "_notify_errored", + "_resolve_watch_block", "_default_watch_config", + "_spawn_watcher", "_kill_watcher", "_tail_stderr", + "_restart_proc_inplace", "cmd_watcher_loop", +] diff --git a/tests/conftest.py b/tests/conftest.py index 6ee581c..29247f1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,15 +30,29 @@ def _load_bgo(): @pytest.fixture def bgo(monkeypatch, tmp_path): - """Fresh bgo module with sandboxed ~/.bgo pointing into tmp_path.""" + """Fresh bgo module with sandboxed ~/.bgo pointing into tmp_path. + + State paths now live in ``bgo_cli._state`` (post-modularization). + Patching both the script module *and* the state module keeps tests + that read either path consistent. + """ sandbox = tmp_path / "bgo_home" sandbox.mkdir() monkeypatch.setenv("HOME", str(sandbox)) if "bgo_mod" in sys.modules: del sys.modules["bgo_mod"] mod = _load_bgo() - mod.BGO_DIR = sandbox / ".bgo" - mod.PROCS_DIR = mod.BGO_DIR / "procs" - mod.LOGS_DIR = mod.BGO_DIR / "logs" + bgo_dir = sandbox / ".bgo" + procs_dir = bgo_dir / "procs" + logs_dir = bgo_dir / "logs" + # Patch both the script's re-exported names AND the source module + # so any code path that resolves via either binding sees the same + # sandbox. + for target in (mod, sys.modules.get("bgo_cli._state")): + if target is None: + continue + target.BGO_DIR = bgo_dir + target.PROCS_DIR = procs_dir + target.LOGS_DIR = logs_dir mod.init_dirs() return mod From fde0bb65b3d06ed1a8382324d8f4ee6626fd7a03 Mon Sep 17 00:00:00 2001 From: tcsenpai Date: Tue, 19 May 2026 13:09:40 +0200 Subject: [PATCH 12/16] refactor(#5): type signatures + docstrings for every cmd_* and helper fu #5 scope: documented + strictly typed Python code. New modules (_notify, _autostart, _tray*, _term, _state, _proc, _watcher) already meet this bar. This commit closes the gap for the cmd_* handlers and top-level helpers that still live in the root bgo script. Annotations added: cmd_start, cmd_stop, cmd_restart, cmd_watch, cmd_unwatch, cmd_status, cmd_logs, cmd_logs_follow, cmd_clean, cmd_resurrect, cmd_restart_stopped, cmd_restart_last, cmd_delete, cmd_autostart, cmd_tray, main -> every signature now (args: argparse.Namespace) -> int and carries a docstring describing inputs, behavior, and exit-code semantics. _clear_screen, _print_status_table, _print_proc_detail -> -> None return annotation and longer docstrings. cmd_start / cmd_stop / cmd_restart / cmd_watch / cmd_status / cmd_logs / cmd_resurrect / cmd_delete also got expanded multi-paragraph docstrings covering modes, side effects, and what each return code means. No behavior changes. 169 tests still pass. --- bgo | 152 +++++++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 119 insertions(+), 33 deletions(-) diff --git a/bgo b/bgo index a997f43..bbeadeb 100755 --- a/bgo +++ b/bgo @@ -90,8 +90,18 @@ from bgo_cli._watcher import ( # noqa: E402 # --- Commands --- -def cmd_start(args): - """Start a process in the background.""" +def cmd_start(args: argparse.Namespace) -> int: + """Start a process in the background. + + Reads the command from ``args.cmd`` (REMAINDER form) or + ``args.command`` (programmatic call). Refuses to overwrite a + proc that's already running under the same name. Writes the + initial state, spawns the watcher if requested, prints a brief + confirmation line plus log-path hint. + + :returns: 0 on success, 1 on any failure (no command supplied, + duplicate name, FileNotFoundError, etc). + """ init_dirs() name = args.name @@ -209,8 +219,17 @@ def cmd_start(args): return 0 -def cmd_stop(args): - """Stop a running process.""" +def cmd_stop(args: argparse.Namespace) -> int: + """Stop a running process. + + Marks the state as stopped BEFORE issuing the kill so a watching + sidecar sees the flag and exits without trying to restart. Kills + the watcher first, then the target, escalating to SIGKILL on + timeout unless ``--force`` was passed (which goes straight to it). + + :returns: 0 if the process is stopped (or was already), 1 on + kill failure. + """ name = args.name info = load_proc(name) @@ -252,8 +271,16 @@ def cmd_stop(args): return 0 -def cmd_restart(args): - """Restart a process. Clears errored state and re-spawns watcher if enabled.""" +def cmd_restart(args: argparse.Namespace) -> int: + """Restart a process; clears errored state and re-spawns the watcher. + + Restart counters are preserved by default so chronic crashers stay + visible across manual restarts. Pass ``--reset-counters`` to wipe + the count to zero explicitly. The watcher (if any) is killed + before the target so it doesn't observe the death and race us. + + :returns: ``cmd_start``'s return code (0 on success, 1 otherwise). + """ name = args.name info = load_proc(name) @@ -293,8 +320,17 @@ def cmd_restart(args): return cmd_start(restart_args) -def cmd_watch(args): - """Attach a watcher to an existing process (or replace its config).""" +def cmd_watch(args: argparse.Namespace) -> int: + """Attach a watcher to an existing running process. + + If ``--reset`` is set OR the proc has no prior watch block, a + fresh default config is built from overrides. Otherwise the + prior config is carried forward with overrides merged in, + errored fields cleared, and ``enabled=True`` re-asserted. + + :returns: 0 on success, 1 if the proc is unknown / not running + / the watcher failed to spawn. + """ name = args.name info = load_proc(name) if not info: @@ -348,8 +384,11 @@ def cmd_watch(args): return 0 -def cmd_unwatch(args): - """Detach watcher from a process (keeps the process running).""" +def cmd_unwatch(args: argparse.Namespace) -> int: + """Detach the watcher from a process. The target keeps running. + + :returns: 0 always (no-op when nothing is being watched). + """ name = args.name info = load_proc(name) if not info: @@ -379,8 +418,8 @@ def _watch_cell(info: dict, level: str | None = None) -> str: return color("green", f"{g['watching']} {w.get('restarts', 0)}") -def _clear_screen(): - """ANSI clear-screen + home cursor. TTY safe.""" +def _clear_screen() -> None: + """Clear the terminal and home the cursor. TTY-only; no-op otherwise.""" if sys.stdout.isatty(): sys.stdout.write("\033[2J\033[H") sys.stdout.flush() @@ -439,8 +478,15 @@ def _pad(text: str, width: int, align: str = "left") -> str: return text + " " * pad -def _print_status_table(rows: list[dict], level: str | None = None): - """Render the status table for a snapshot.""" +def _print_status_table(rows: list[dict], level: str | None = None) -> None: + """Render the status table for a snapshot at the given rendering level. + + Columns: NAME / STATUS / PID / CPU / MEM / UPTIME / WATCH / + COMMAND. The COMMAND column flexes to fill the terminal width; + the rest have fixed widths sized to fit their longest expected + value. Fancy mode draws Unicode box-drawing borders around the + table; normal mode prints ASCII rules instead. + """ if not rows: return level = level or _detect_table_level() @@ -548,8 +594,19 @@ def _level_from_args(args) -> str | None: return None -def cmd_status(args): - """Show status of all processes.""" +def cmd_status(args: argparse.Namespace) -> int: + """Show status of all processes (or detail for one). + + Modes (mutually exclusive, evaluated in order): + + 1. ``--json`` — emit JSON for ``args.name`` or all procs. + 2. ``-w`` / ``--watch`` — full-screen auto-refresh until Ctrl-C. + 3. ``args.name`` set — detailed single-proc view. + 4. default — one-shot table for every registered proc. + + :returns: 0 on success, 1 if ``--json`` was requested for a + specific name that doesn't exist. + """ # JSON output mode bypasses everything else if getattr(args, "json", False): procs = load_all_procs() @@ -610,8 +667,13 @@ def cmd_status(args): return 0 -def _print_proc_detail(info: dict): - """Print detailed info for a single process.""" +def _print_proc_detail(info: dict) -> None: + """Print the detail card for a single process state ``info`` dict. + + Lists status / PID / command / cwd / start time / live CPU+MEM + (when running) / log sizes / watch config + restart counters / + errored reason + last stderr tail (when errored). + """ pid = info["pid"] running = is_running(pid) status_str = color("green", "online") if running else color("red", "stopped") @@ -686,8 +748,17 @@ def _human_size(n: int) -> str: return f"{n:.1f}TB" -def cmd_logs(args): - """Show logs for a process.""" +def cmd_logs(args: argparse.Namespace) -> int: + """Show logs for a process. + + Stream selection: ``--watcher`` -> watcher event log; + ``--stderr`` -> stderr; ``--stdout`` (or default) -> stdout. + With ``--follow`` / ``-f``, exec ``tail -f`` for live streaming; + otherwise tail the last ``-n`` lines (default 50) and exit. + + :returns: 0 on success, 1 if the proc is unknown or the log + file disappeared between stat and open. + """ name = args.name info = load_proc(name) @@ -778,16 +849,16 @@ def cmd_logs(args): return 0 -def cmd_logs_follow(args): - """Follow logs for a process (shorthand for logs -f).""" +def cmd_logs_follow(args: argparse.Namespace) -> int: + """Follow logs for a process. Shorthand for ``bgo logs -f``.""" args.follow = True if not hasattr(args, "lines") or args.lines is None: args.lines = 10 return cmd_logs(args) -def cmd_clean(args): - """Remove stopped processes from state.""" +def cmd_clean(args: argparse.Namespace) -> int: + """Drop every stopped proc from state (keeps log files in place).""" procs = load_all_procs() cleaned = [] @@ -807,8 +878,16 @@ def cmd_clean(args): return 0 -def cmd_resurrect(args): - """Restart all processes that were running before shutdown/reboot.""" +def cmd_resurrect(args: argparse.Namespace) -> int: + """Restart every proc whose last recorded status was ``running``. + + Used by the systemd / launchd autostart entry to bring back + managed procs after a reboot or session login. Procs that the + user manually stopped (status=stopped) are left alone. + + :returns: 0 if all candidates restarted (or none existed), 1 if + any failed to start. + """ procs = load_all_procs() if not procs: @@ -953,7 +1032,7 @@ def _restart_one(name: str, info: dict) -> int: return cmd_start(restart_args) -def cmd_restart_stopped(args): +def cmd_restart_stopped(args: argparse.Namespace) -> int: """Restart procs that are not currently running. Includes both explicitly stopped procs AND procs whose state says @@ -1028,7 +1107,7 @@ def cmd_restart_stopped(args): return 0 -def cmd_restart_last(args): +def cmd_restart_last(args: argparse.Namespace) -> int: """Restart processes ordered by most-recent-first. With --all: restart every not-running proc in most-recent order. @@ -1092,8 +1171,14 @@ def cmd_restart_last(args): return 0 -def cmd_delete(args): - """Delete a process from state (stop if running first).""" +def cmd_delete(args: argparse.Namespace) -> int: + """Delete a process from state (stop it first if running). + + Drops log files too unless ``--keep-logs`` is passed. Asks for + confirmation when stopping a running proc, unless ``-y``. + + :returns: 0 on success, 1 if the proc is unknown. + """ name = args.name info = load_proc(name) @@ -1121,7 +1206,7 @@ def cmd_delete(args): # --- Optional feature handlers --- -def cmd_autostart(args): +def cmd_autostart(args: argparse.Namespace) -> int: """Install / uninstall / inspect login autostart for ``bgo resurrect``. Subcommands (``args.action``): @@ -1181,7 +1266,7 @@ def cmd_autostart(args): return 1 -def cmd_tray(args): +def cmd_tray(args: argparse.Namespace) -> int: """Launch the system-tray icon (optional extra ``bgo-cli[tray]``). If PySide6 is not importable, delegates to @@ -1218,7 +1303,8 @@ def cmd_tray(args): # --- CLI --- -def main(): +def main() -> int: + """Entry point. Returns the process exit code for ``sys.exit``.""" init_dirs() # Hidden subcommand for internal watcher loop From 2fedd2aceb5ae2aac15db9b69a8f72ddefa1c10e Mon Sep 17 00:00:00 2001 From: tcsenpai Date: Tue, 19 May 2026 13:13:59 +0200 Subject: [PATCH 13/16] docs(#7): GH-style wiki under docs/, slim README to index + quickstart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fu #7 scope: GH-style wiki linking documentation. As lead I picked an in-repo docs/ folder over GitHub's separate .wiki.git repo because: - The wiki git repo is created via the GitHub web UI, can't ship with the main branch, and isn't reviewable in PRs. - An in-repo docs/ folder renders natively in the GitHub web UI for navigation, supports MkDocs / Astro / GitHub Pages later, AND participates in normal review. Structure: docs/README.md — index linking every page docs/installation.md — uv / pipx / pip / install.sh / manual docs/commands.md — full CLI reference docs/watch-mode.md — auto-restart + fast-crash policy docs/notifications.md — desktop notifier backends + gating docs/autostart.md — systemd-user / launchd integration docs/tray.md — PySide6 tray, per-platform notes docs/architecture.md — module dep graph, storage layout, watcher protocol docs/faq.md — common questions and gotchas docs/contributing.md — dev setup, tests, patches, release Pages link to each other consistently (e.g. tray.md references autostart.md for 'run tray at login'; commands.md links to watch-mode .md for tunable detail). Root README rewritten as a slim quickstart + feature highlights + a Documentation table that points into docs/. Went 417 -> 106 lines. Duplicated content (full command tables, install matrices, tray caveats) lives only in docs/ now. 169 tests still pass — no code touched in this commit. --- README.md | 413 ++++++------------------------------------ docs/README.md | 38 ++++ docs/architecture.md | 127 +++++++++++++ docs/autostart.md | 77 ++++++++ docs/commands.md | 86 +++++++++ docs/contributing.md | 78 ++++++++ docs/faq.md | 101 +++++++++++ docs/installation.md | 80 ++++++++ docs/notifications.md | 55 ++++++ docs/tray.md | 133 ++++++++++++++ docs/watch-mode.md | 58 ++++++ 11 files changed, 884 insertions(+), 362 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/architecture.md create mode 100644 docs/autostart.md create mode 100644 docs/commands.md create mode 100644 docs/contributing.md create mode 100644 docs/faq.md create mode 100644 docs/installation.md create mode 100644 docs/notifications.md create mode 100644 docs/tray.md create mode 100644 docs/watch-mode.md diff --git a/README.md b/README.md index 5d4c6d6..97c8910 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# bgo - Background Go +# bgo — Background Go Lightweight, zero-dep background process manager inspired by pm2. Detach any binary or script from your shell with one command. @@ -7,411 +7,100 @@ Detach any binary or script from your shell with one command. [![Python](https://img.shields.io/pypi/pyversions/bgo-cli.svg)](https://pypi.org/project/bgo-cli/) [![License](https://img.shields.io/pypi/l/bgo-cli.svg)](LICENSE) -PyPI: https://pypi.org/project/bgo-cli/ +PyPI: ## Features -- 🚀 **Simple syntax** — `bgo -- ` -- 🐧 **Unix-style aliases** — `bgo open` / `kill` / `rm` / `ls` -- 📊 **Status monitoring** — CPU, memory, uptime in plain / normal / fancy tables (auto-detect) -- 📝 **Log management** — stdout / stderr / watcher logs with follow mode -- 🔄 **Lifecycle** — start, stop, restart, restart-stopped, restart-last, resurrect -- 👁 **Watch mode** — auto-restart crashed processes with fast-crash backoff -- 🧹 **Auto-cleanup** — clean stopped procs; keep logs on delete if desired -- 🤖 **Scriptable** — `--json` output for any pipeline -- ⚡ **Zero runtime dependencies** — pure Python 3.9+ +- **Simple syntax** — `bgo -- ` +- **Unix-style aliases** — `bgo open` / `kill` / `rm` / `ls` +- **Status monitoring** — CPU, memory, uptime; plain / normal / fancy + tables with auto-detection +- **Log management** — stdout / stderr / watcher logs with follow mode +- **Lifecycle** — start, stop, restart, restart-stopped, restart-last, + resurrect +- **Watch mode** — auto-restart crashed processes with fast-crash backoff +- **Desktop notifications** on errored transitions +- **Login autostart** via systemd-user (Linux) or LaunchAgent (macOS) +- **Tray icon** (optional) — PySide6, gear+dot status indicator, + KDE/Hyprland/GNOME/macOS +- **Scriptable** — `--json` output for any pipeline +- **Zero runtime dependencies** in the core; one optional extra for the tray + +## Quick start -## Installation - -### Recommended: `uv` - -[`uv`](https://docs.astral.sh/uv/) installs `bgo-cli` into an isolated -environment and links the `bgo` command onto your PATH. No global -Python pollution, no virtualenv to manage. - -```bash -uv tool install bgo-cli -``` - -Upgrade: -```bash -uv tool upgrade bgo-cli -``` - -Uninstall: ```bash -uv tool uninstall bgo-cli -``` - -### Alternatives - -```bash -# pipx (also isolated, very similar to uv tool) -pipx install bgo-cli - -# Plain pip (installs into the active environment) -pip install bgo-cli +uv tool install bgo-cli # see docs/installation.md for alternatives -# Install script — builds nothing, just copies the single-file script -./install.sh # /usr/local/bin (sudo) -./install.sh --local # ~/.local/bin (no sudo) -./install.sh --help # --force / --uninstall - -# Or fully manual — bgo is a single file -cp bgo ~/.local/bin/ # or /usr/local/bin/ -ln -s "$(pwd)/bgo" ~/.local/bin/bgo -``` - -### Verify - -```bash -bgo --help -bgo ls # no procs registered yet -``` - -## Quick Start - -```bash -# Start a process bgo myserver -- python3 -m http.server 8080 -bgo open myserver -- python3 -m http.server 8080 # alias - -# Check status -bgo status # full alias chain: status / ls / list bgo ls - -# Status detail for one proc (or: bgo ) -bgo myserver - -# View logs -bgo logs myserver -bgo logs myserver -f # follow (tail -f) -bgo follow myserver # alias for logs -f - -# Stop / restart / delete -bgo stop myserver # or: bgo kill myserver +bgo logs myserver -f bgo restart myserver -bgo delete myserver # or: bgo rm myserver -bgo rm myserver --keep-logs # preserve log files - -# Bare `bgo` prints help; `bgo ` errors out (never silently spawns) -bgo +bgo stop myserver ``` -## Commands - -### Lifecycle +`bgo` with no args prints help. `bgo ` errors out rather than +silently spawning something. -| Command | Description | -|---|---| -| `bgo start -- ` | Start a process (alias: `open`) | -| `bgo -- ` | Shorthand for start | -| `bgo stop ` | Stop (SIGTERM, alias: `kill`) | -| `bgo stop -f` | Force kill (SIGKILL) | -| `bgo restart ` | Restart; preserves watch state and counters | -| `bgo restart --reset-counters` | Also zero `watch.restarts` | -| `bgo restart-stopped` | Pick stopped procs to restart (interactive) | -| `bgo restart-stopped --all` | Restart every stopped proc | -| `bgo restart-stopped ...` | Restart named stopped procs | -| `bgo restart-last` | Menu sorted most-recent-first | -| `bgo restart-last --all` | Restart all not-running procs in recent order | -| `bgo resurrect` | Restart all procs that were running before shutdown | -| `bgo delete ` | Remove proc + logs (alias: `rm`) | -| `bgo delete --keep-logs` | Remove proc, keep logs | -| `bgo clean` | Drop all stopped procs from the list | +## Documentation -### Inspection +Long-form docs live in [`docs/`](docs/README.md): -| Command | Description | +| Topic | Where | |---|---| -| `bgo status` | Process table (alias: `ls`, `list`) | -| `bgo status ` | Detail view for one proc | -| `bgo status -w` | Watch mode (auto-refresh every 2s) | -| `bgo status -w --interval N` | Custom refresh interval | -| `bgo status --json` | Machine-readable output | -| `bgo status --plain` | ASCII-only output (no color, no glyphs) | -| `bgo status --fancy` | Force Unicode box-drawing rendering | -| `bgo ` | Shorthand for `bgo status ` | -| `bgo logs ` | Last 50 lines | -| `bgo logs -f` | Follow logs | -| `bgo logs -n 100` | Last 100 lines | -| `bgo logs --stderr` | Only stderr | -| `bgo logs --watcher` | Watcher event log | -| `bgo follow ` | Alias for `logs -f` (also: `tail`) | - -### Watch mode - -| Command | Description | -|---|---| -| `bgo start -w -- ` | Start with watcher attached | -| `bgo -w ` | Direct mode with watcher | -| `bgo watch ` | Attach watcher to a running proc | -| `bgo watch --interval N --min-uptime N --on-fast-crash MODE` | Tune | -| `bgo unwatch ` | Detach watcher, keep proc | - -## Examples - -```bash -# Python HTTP server -bgo web -- python3 -m http.server 8080 - -# Node.js app with watcher -bgo -w api -- npm start - -# Custom binary with working directory -bgo start dashboard --cwd /opt/app -- node server.js - -# Inspect one proc -bgo web - -# Scripted: stop every online proc via JSON -bgo status --json | python3 -c ' -import json, sys, subprocess -for p in json.load(sys.stdin): - if p["status"] == "online": - subprocess.run(["bgo", "stop", p["name"]]) -' -``` - -## Status Table - -The table auto-detects terminal capabilities and picks the best rendering: - -| Level | Trigger | What you get | -|---|---|---| -| `plain` | non-TTY (pipes, CI logs), `TERM=dumb`, or `--plain` | ASCII only, no color, no glyphs | -| `normal` | TTY without UTF-8 locale | ANSI color + ASCII rules | -| `fancy` | TTY + UTF-8 locale (default) | ANSI color + Unicode box-drawing | +| Install paths (uv / pipx / pip / install.sh / manual) | [docs/installation.md](docs/installation.md) | +| Full CLI reference | [docs/commands.md](docs/commands.md) | +| Watch mode (auto-restart + fast-crash policy) | [docs/watch-mode.md](docs/watch-mode.md) | +| Desktop notifications | [docs/notifications.md](docs/notifications.md) | +| Login autostart (systemd / launchd) | [docs/autostart.md](docs/autostart.md) | +| Tray icon (optional, PySide6) | [docs/tray.md](docs/tray.md) | +| Module architecture | [docs/architecture.md](docs/architecture.md) | +| FAQ | [docs/faq.md](docs/faq.md) | +| Contributing | [docs/contributing.md](docs/contributing.md) | -Override with `--plain` / `--fancy` or `BGO_TABLE=plain|normal|fancy`. +## Sample status table -Sample (fancy): ``` -┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ NAME STATUS PID CPU MEM UPTIME WATCH COMMAND ┃ ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ ┃ web ● online 12345 2.5 0.1 00:05 ✓ 0 python3 -m http.server 8080 ┃ ┃ api ● online 12346 0.0 0.0 01:23 ✓ 3 node server.js ┃ ┃ worker ○ stopped - - - - ⚠ errored python3 worker.py ┃ -┃ batch ● online 12347 0.0 0.0 02:11 · ./batch ┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -Total: 4 | ● online: 3 | ○ stopped: 1 +Total: 3 | ● online: 2 | ○ stopped: 1 ⚠ 1 errored: worker — 4 consecutive fast-crashes bgo logs worker --watcher | bgo restart worker ``` -CPU / MEM / uptime are pulled in a single batched `ps` call regardless of how many procs are running. - -## Watch Mode - -Watch mode runs a sidecar process per watched proc that polls the -target and restarts it on crash. State (restart count, error reason, -last stderr tail) is recorded in the proc JSON and surfaced via -`bgo status`. - -### Quick start - -```bash -bgo start -w myapi -- node server.js # start with watcher -bgo -w myapi node server.js # direct-mode variant -bgo watch myapi # attach to a running proc -bgo watch myapi --interval 5 --min-uptime 3 --on-fast-crash backoff -bgo unwatch myapi # detach, keep proc -bgo logs myapi --watcher # inspect events -``` - -### Fast-crash policy - -If a process dies before `--min-uptime` (default 2s) it's a *fast crash*. Reaction depends on `--on-fast-crash`: - -| Mode | Behavior | -|---|---| -| `backoff` (default) | Wait 2s, retry. Then 4s, then 8s. After 4 consecutive fast-crashes, mark `errored` and exit watcher. | -| `stop` | Mark `errored` on the first fast-crash. | -| `retry` | Retry indefinitely, capped at 8s backoff. | - -When a proc enters `errored`: -- WATCH column shows `⚠ errored` (or `[!] errored` in plain). -- Status footer summarizes errored procs and hints at the recovery commands. -- `bgo status ` detail shows the error reason and last stderr tail. -- `bgo restart ` clears the errored flag and re-spawns the watcher. Restart counter is **preserved** by default — use `--reset-counters` to zero it. - -### Tunables - -| Flag | Default | Notes | -|---|---|---| -| `--interval N` | 3 | Poll interval after the initial uptime window | -| `--min-uptime N` | 2 | Crash threshold; sub-window polled at high frequency | -| `--on-fast-crash MODE` | `backoff` | One of `backoff`, `stop`, `retry` | -| `--reset` | off | `bgo watch` only — reset prior watch config to defaults | - -## Desktop notifications - -`bgo` fires a desktop notification when a watched process enters the -`errored` state (fast-crash budget exhausted, restart failed, etc.). -Zero new dependencies — it shells out to the platform's native -notifier: - -- Linux: `notify-send` (libnotify; install via your distro). -- macOS: built-in `osascript`. Falls back to `terminal-notifier` if - available. - -Notifications are best-effort — if no notifier is reachable, the -watcher carries on silently. - -| Env var | Values | Default | Effect | -|---|---|---|---| -| `BGO_NOTIFY` | `off`, `errors`, `all` | `errors` | What to fire on | -| `BGO_NOTIFY_CMD` | argv template with `{title}` / `{body}` | — | Override the backend entirely | - -## Autostart at login - -`bgo autostart` installs a per-user service that runs `bgo resurrect` -on session start, restoring every process that was registered as -`running` at shutdown. Backends are auto-detected: - -- Linux → systemd user unit at `~/.config/systemd/user/bgo-resurrect.service` -- macOS → LaunchAgent at `~/Library/LaunchAgents/sh.discus.bgo.resurrect.plist` - -```bash -bgo autostart install # install resurrect at login -bgo autostart install --tray # also install the tray icon at login -bgo autostart status # show what's installed -bgo autostart status --json # machine-readable -bgo autostart uninstall # remove -bgo autostart uninstall --tray # remove tray entry only -``` - -On Linux, services only run after the user logs in. To start the -resurrect unit before a graphical session exists (e.g. headless -servers), enable lingering once: `loginctl enable-linger $USER`. -Neither flag is set automatically — it modifies system state. - -## Tray icon (optional) - -`bgo tray` runs a system-tray icon (a gear with a colored status dot) -that lists all registered procs and exposes one-click actions for each. - -**Icon dot color**: -- 🟢 green — at least one proc running, none errored -- 🔴 red — any proc errored -- ⚫ gray — empty or all stopped - -**Menu**: each proc gets a submenu prefixed with a Unicode status glyph -(● online / ○ stopped / ⚠ errored). The submenu offers Start (or -Restart when online) / Stop / Open logs. Global entries: Resurrect all -/ Refresh now / Quit. The menu rebuilds from `~/.bgo/procs/*.json` -every few seconds; every action shells out to `bgo` so behavior -matches the CLI exactly. - -**Open logs** spawns `bgo logs -f` in a new terminal window -(live tail with formatting), not the raw log file in an editor. Linux -probes a curated list (kitty, alacritty, wezterm, foot, ghostty, -gnome-terminal, konsole, xfce4-terminal, tilix, xterm) and picks the -first one on PATH. macOS uses AppleScript to spawn Terminal.app. Set -`BGO_TERMINAL='kitty --'` (or any `binary [exec-flag]`) for an -explicit override; `BGO_TERMINAL=iterm` switches macOS to iTerm2. - -**Left- and right-click** both open the menu. Middle-click does too. -Right-click is always handled by the host directly. Left/middle-click -is wired by us via Qt's `activated` signal — most hosts deliver it, -but a few (notably some KDE Plasma configurations that bind a custom -left-click action) may swallow it; in that case right-click still -works and the host setting is overrideable in tray config. Set -`BGO_TRAY_DEBUG=1` to log the activation reason to stderr while -debugging. - -### Platform support - -Tray UI uses **PySide6** (Qt for Python, LGPL). One library, native -support on every target: - -| Platform | What happens | -|---|---| -| **macOS** | Native `NSStatusItem` in the menu bar | -| **KDE Plasma 6 (Wayland or X11)** | StatusNotifierItem, no setup | -| **Hyprland / sway + waybar** | SNI via waybar's `tray` module | -| **GNOME Wayland** | Needs the AppIndicator shell extension (see below) | - -### Install - -PySide6 is shipped as an optional extra to keep the core install -zero-dep: - -```bash -# uv (recommended) -uv tool install bgo-cli --with PySide6 - -# pipx -pipx install bgo-cli -pipx inject bgo-cli PySide6 - -# pip -pip install 'bgo-cli[tray]' -``` - -If you forget, the first run of `bgo tray` detects your installer -(uv tool / pipx / pip) and offers to inject the dep for you. Skip -the confirmation with `--auto-install` or `BGO_TRAY_AUTOINSTALL=1`. - -### Run in the background - -The tray is just another command — run it under `bgo` itself: - -```bash -bgo start -w bgotray -- bgo tray # -w auto-restarts on crash -``` - -Or, simpler, install it as a login autostart entry: - -```bash -bgo autostart install --tray # tray starts on every login -``` - -### GNOME Wayland prerequisite - -GNOME ships no system-tray host of its own. Install the -`AppIndicator and KStatusNotifierItem Support` shell extension once: - -```bash -sudo dnf install gnome-shell-extension-appindicator # Fedora -sudo apt install gnome-shell-extension-appindicator # Debian/Ubuntu -gnome-extensions enable appindicatorsupport@rgcjonas.gmail.com -``` - -Log out and back in. KDE / Hyprland / macOS users do not need this -step. - -### Tuning - -| Flag / env | Default | Effect | -|---|---|---| -| `--poll N`, `BGO_TRAY_POLL` | `3` | Snapshot refresh interval (seconds) | -| `--auto-install`, `BGO_TRAY_AUTOINSTALL=1` | off | Skip the install prompt | -| `BGO_TERMINAL` | (auto-probe) | Terminal emulator for Open-logs (`'kitty --'`, `iterm`, etc.) | -| `BGO_TRAY_DEBUG=1` | off | Log activation reasons to stderr | +Override the rendering with `--plain` / `--fancy` or +`BGO_TABLE=plain|normal|fancy`. ## Storage -- State: `~/.bgo/procs/.json` — one file per process, written atomically (tmp + `os.replace`) -- Logs: `~/.bgo/logs/.out.log`, `.err.log`, `.watcher.log` +- State: `~/.bgo/procs/.json` — one file per process, atomic writes. +- Logs: `~/.bgo/logs/.{out,err,watcher}.log`. + +Full layout: [docs/architecture.md](docs/architecture.md#storage-layout). ## Testing ```bash -python3 -m pytest tests/ -v +uv run pytest tests/ -v ``` -169 tests covering state I/O, atomic writes, command-shape detection, name derivation, liveness + zombie filtering, watch-config inheritance, table rendering, desktop notifications, login autostart (systemd-user / launchd), and tray menu construction. +169 tests covering state I/O, atomic writes, command-shape detection, +name derivation, liveness + zombie filtering, watch-config inheritance, +table rendering, desktop notifications, login autostart, and tray menu +construction. ## Requirements - Python 3.10+ -- Linux or macOS (zombie detection is platform-aware: `/proc` on Linux, `ps -o stat=` on macOS) +- Linux or macOS - Optional `[tray]` extra: PySide6 (Qt for Python, LGPL) ## License -MIT +[MIT](LICENSE). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..e5c2b5e --- /dev/null +++ b/docs/README.md @@ -0,0 +1,38 @@ +# bgo documentation + +Welcome to the bgo wiki. This folder is the canonical place for +long-form documentation; the [project root README](../README.md) is a +quickstart and feature overview that links here for detail. + +## Contents + +### Getting started + +- **[Installation](installation.md)** — uv (recommended), pipx, pip, + install.sh, manual single-file copy. +- **[Commands](commands.md)** — full CLI reference: lifecycle, + inspection, watch mode, autostart, tray. + +### Features + +- **[Watch mode](watch-mode.md)** — auto-restart on crash, fast-crash + policy (`backoff` / `stop` / `retry`), tunables. +- **[Desktop notifications](notifications.md)** — fired on errored + state transitions. Zero-dep, native per-platform backends. +- **[Login autostart](autostart.md)** — systemd user units on Linux, + LaunchAgents on macOS. Brings procs back after reboot. +- **[Tray icon](tray.md)** — PySide6 system-tray icon with gear+dot + status indicator. Works on KDE / Hyprland / GNOME (with extension) + / macOS. + +### Reference + +- **[Architecture](architecture.md)** — module layout, where each + concern lives, the watcher protocol. +- **[FAQ](faq.md)** — common questions and gotchas. +- **[Contributing](contributing.md)** — dev setup, test loop, where to + send patches. + +## License + +[MIT](../LICENSE). diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..13d59b0 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,127 @@ +# Architecture + +bgo started as a single-file Python script. Over time the optional +features (notifications, autostart, tray) demanded proper modules. As +of the `dev`-branch refactor, the source split is: + +``` +bgo # entry script — argparse + cmd_* handlers +src/bgo_cli/ + __init__.py # package shim; force-include target for wheels + __main__.py # `python -m bgo_cli` -> main() + _term.py # ANSI colors, glyphs, table-level detection + _state.py # proc JSON I/O, dir layout + _proc.py # PID checks, kill_process, ps batch + _watcher.py # watch loop + helpers + _notify.py # desktop notifications + _autostart.py # systemd-user / launchd install + _tray.py # PySide6 tray icon + _tray_install.py # uv/pipx/pip auto-install of PySide6 +tests/ + conftest.py # bgo module loader + sandbox fixture + test_*.py # 169 tests +``` + +## Module dependency graph + +``` + ┌─────────┐ + │ _term │ (no deps) + └────┬────┘ + │ + ┌────┴────┐ + │ _proc │ (uses _term for one error message) + └────┬────┘ + │ + ┌────┴────┐ ┌─────────┐ + │ _state │ │ _notify │ (no deps) + └────┬────┘ └────┬────┘ + │ │ + └─────────┬─────────┘ + │ + ┌────┴────┐ + │_watcher │ (lazy-imports _notify) + └────┬────┘ + │ + ┌────┴────┐ ┌────────────┐ ┌──────────────┐ + │ bgo │ │ _autostart │ │ _tray / │ + │ (cmd_*) │ │ │ │ _tray_install│ + └─────────┘ └────────────┘ └──────────────┘ +``` + +Cycles are forbidden. `_notify` and the tray modules are lazy-imported +from inside the script so missing optional deps never break a fresh +install. + +## Storage layout + +``` +~/.bgo/ +├── procs/ +│ ├── web.json # one file per registered proc +│ ├── api.json +│ └── … +└── logs/ + ├── web.out.log # stdout + ├── web.err.log # stderr + ├── web.watcher.log # watcher event log + ├── api.out.log + └── … +``` + +Writes go through `save_proc` which uses tmp-file + `os.replace` for +atomicity. The watcher and the CLI can race writes safely. + +## The `bgo` script + +The root script is both: + +1. The user-facing executable on `$PATH`. +2. The force-included `_core.py` payload in the wheel (see + `pyproject.toml` `[tool.hatch.build.targets.wheel.force-include]`). + +It owns the argparse setup, direct-mode parsing (e.g. `bgo myapp -- +node server.js` without `start`), and the `cmd_*` handlers. The +handlers shell out to the modules above for state, processes, and +external features. + +## Watcher protocol + +When `bgo start -w …` (or `bgo watch …`) registers a watch block, +the script spawns: + +``` +$SHELL_OF_BGO bgo __watcher__ +``` + +…as a detached subprocess (`start_new_session=True`). The watcher's +PID is recorded in `info["watch"]["watcher_pid"]`. The watcher polls +the target every `interval` seconds, computes uptime from +`started_at`, and reacts to crashes per the `on_fast_crash` policy. + +On any errored transition the watcher fires a desktop notification +via `bgo_cli._notify` and exits. + +See [Watch mode](watch-mode.md) for the user-facing surface and +fast-crash policy details. + +## Tests + +`tests/conftest.py` exposes a `bgo` fixture that loads the bare +script via `importlib.util.spec_from_loader` and sandboxes `~/.bgo/` +into a `tmp_path` directory. Tests for the modules import them +directly (`from bgo_cli import _notify`), so they don't pay the +script-load cost. + +```bash +uv run pytest tests/ # all 169 tests +uv run pytest tests/test_notify.py -v # one module +``` + +## Future work + +- Extract `cmd_*` handlers from the root script into + `bgo_cli._commands` (tracked in [fu #6 partial + completion](#)). Status rendering, the interactive multiselect, + and the argparse front-end can follow. +- mypy/pyright strict pass over the script (modules already type-clean). diff --git a/docs/autostart.md b/docs/autostart.md new file mode 100644 index 0000000..6167b8b --- /dev/null +++ b/docs/autostart.md @@ -0,0 +1,77 @@ +# Login autostart + +`bgo autostart` installs a per-user service that runs `bgo resurrect` +on session start, restoring every process that was registered as +`running` at shutdown. Backends are auto-detected: + +| Platform | Backend | Path | +|---|---|---| +| Linux | systemd user unit | `~/.config/systemd/user/bgo-resurrect.service` | +| macOS | LaunchAgent | `~/Library/LaunchAgents/sh.discus.bgo.resurrect.plist` | + +## Commands + +```bash +bgo autostart install # resurrect at login +bgo autostart install --tray # also launch the tray icon at login +bgo autostart status # show what's installed +bgo autostart status --json # machine-readable +bgo autostart uninstall # remove resurrect +bgo autostart uninstall --tray # remove tray entry only +``` + +`install` is idempotent — re-running overwrites the unit content but +doesn't duplicate registrations. + +## Linux: lingering + +systemd user units only run after the user logs in. To start the +resurrect unit before a graphical session exists (e.g. headless +servers, automated reboots), enable lingering once: + +```bash +loginctl enable-linger $USER +``` + +This modifies system state, so bgo does **not** run it automatically +— enable it yourself if you need that behavior. + +## macOS: LaunchAgent + +The plist is loaded via `launchctl bootstrap` (modern) with a fallback +to `launchctl load -w` (legacy). `RunAtLoad=true` triggers on login. + +```bash +# Manual inspection +plutil -p ~/Library/LaunchAgents/sh.discus.bgo.resurrect.plist +launchctl list | grep sh.discus.bgo +``` + +## Tray autostart + +`bgo autostart install --tray` writes a separate entry: + +| Platform | Path | +|---|---| +| Linux | `~/.config/autostart/bgo-tray.desktop` (XDG spec) | +| macOS | `~/Library/LaunchAgents/sh.discus.bgo.tray.plist` | + +The tray entry is independent of the resurrect entry — install both +for the typical "tray plus restored procs at login" workflow: + +```bash +bgo autostart install # resurrect +bgo autostart install --tray # tray +``` + +See [Tray icon](tray.md) for tray-specific requirements (PySide6 +install, GNOME extension, etc). + +## How `resurrect` decides what to restart + +A proc is restarted iff: + +1. Its state file says `status: running`. +2. Its PID is not currently alive (i.e. it died with the session). + +Procs the user manually stopped (`status: stopped`) are left alone. diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..e40e67a --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,86 @@ +# Commands + +Complete CLI reference. See also [`bgo --help`](#help) for the +in-shell summary. + +## Lifecycle + +| Command | Description | +|---|---| +| `bgo start -- ` | Start a process (alias: `open`) | +| `bgo -- ` | Shorthand for start | +| `bgo stop ` | Stop (SIGTERM; alias: `kill`) | +| `bgo stop -f` | Force kill (SIGKILL) | +| `bgo restart ` | Restart; preserves watch state and counters | +| `bgo restart --reset-counters` | Also zero `watch.restarts` | +| `bgo restart-stopped` | Pick stopped procs to restart (interactive) | +| `bgo restart-stopped --all` | Restart every stopped proc | +| `bgo restart-stopped ...` | Restart named stopped procs | +| `bgo restart-last` | Menu sorted most-recent-first | +| `bgo restart-last --all` | Restart all not-running procs (recent first) | +| `bgo resurrect` | Restart procs that were running before shutdown | +| `bgo delete ` | Remove proc + logs (alias: `rm`) | +| `bgo delete --keep-logs` | Remove proc, keep logs | +| `bgo clean` | Drop all stopped procs from the list | + +## Inspection + +| Command | Description | +|---|---| +| `bgo status` | Process table (alias: `ls`, `list`) | +| `bgo status ` | Detail view for one proc | +| `bgo status -w` | Watch mode (auto-refresh every 2s) | +| `bgo status -w --interval N` | Custom refresh interval | +| `bgo status --json` | Machine-readable output | +| `bgo status --plain` | ASCII-only (no color, no glyphs) | +| `bgo status --fancy` | Force Unicode box-drawing rendering | +| `bgo ` | Shorthand for `bgo status ` | +| `bgo logs ` | Last 50 lines | +| `bgo logs -f` | Follow logs | +| `bgo logs -n 100` | Last 100 lines | +| `bgo logs --stderr` | Only stderr | +| `bgo logs --watcher` | Watcher event log | +| `bgo follow ` | Alias for `logs -f` (also: `tail`) | + +## Watch mode + +| Command | Description | +|---|---| +| `bgo start -w -- ` | Start with watcher attached | +| `bgo -w ` | Direct mode with watcher | +| `bgo watch ` | Attach watcher to a running proc | +| `bgo watch --interval N --min-uptime N --on-fast-crash MODE` | Tune | +| `bgo unwatch ` | Detach watcher; proc keeps running | + +Full details: [Watch mode](watch-mode.md). + +## Autostart + +| Command | Description | +|---|---| +| `bgo autostart install` | Run `bgo resurrect` at login | +| `bgo autostart install --tray` | Launch the tray icon at login | +| `bgo autostart uninstall [--tray]` | Remove the autostart entry | +| `bgo autostart status [--json]` | Show what's installed | + +Full details: [Login autostart](autostart.md). + +## Tray (optional) + +| Command | Description | +|---|---| +| `bgo tray` | Launch the system-tray icon | +| `bgo tray --poll N` | Poll interval (default 3s) | +| `bgo tray --auto-install` | Inject PySide6 without prompting | + +Full details: [Tray icon](tray.md). + +## Help + +```bash +bgo --help +bgo --help +``` + +`bgo` (no args) prints usage. `bgo ` errors out without +silently spawning anything. diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..8cc4663 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,78 @@ +# Contributing + +## Dev setup + +```bash +git clone https://github.com/tcsenpai/bgo.git +cd bgo +uv sync --extra dev --extra tray +``` + +`--extra dev` pulls pytest, build, twine. `--extra tray` pulls +PySide6 so you can develop the tray module without a separate install. + +## Running + +The root `bgo` script is the canonical executable. From the repo: + +```bash +./bgo --help +./bgo status +``` + +To run via the installed wheel (matches end-user experience): + +```bash +uv tool install -e . --with PySide6 +bgo --help +``` + +The `-e` flag is editable — edits to source land immediately, no +re-install needed unless `pyproject.toml` changes. + +## Tests + +```bash +uv run pytest tests/ -q # all tests +uv run pytest tests/test_tray.py -v # one module +uv run pytest -k notify # by keyword +``` + +All 169 tests should pass before opening a PR. The tray's PySide6 +event loop is intentionally not unit-tested (Qt internals); the +toolkit-agnostic helpers (`build_menu_spec`, `_status_label`, +`_status_glyph`, `_aggregate_status`, `_icon_svg`) are. + +## Linting / types + +No required linter is wired into CI yet. The newer modules +(`_notify`, `_autostart`, `_tray*`, `_term`, `_state`, `_proc`, +`_watcher`) are strictly typed and aim for mypy-clean — please keep +new code at that bar. + +The remaining cmd_* handlers in the root `bgo` script were typed in +the [fu #5 sweep](#) — every `cmd_*` signature is +`(args: argparse.Namespace) -> int` with docstrings describing +inputs / behavior / exit codes. + +## Project structure + +See [Architecture](architecture.md). + +## Releasing + +The repo includes `publish.sh` for version bumps and PyPI publishes. +See `PUBLISHING.md` (root) for the full flow. PRs should not bump +the version — leave that to the maintainer. + +## Commit style + +Conventional Commits, single line subject ≤ 72 chars, body explains +*why* not *what*. Imperative mood (`add X`, not `added X`). + +## Where to send patches + +- Bugs and features: GitHub issues at `tcsenpai/bgo`. +- PRs: `dev` branch (not `main`); main is reserved for releases. +- Security: open a private security advisory in the GitHub UI rather + than a public issue. diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 0000000..b07604b --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,101 @@ +# FAQ + +## Why does `bgo` need a separate watcher process? + +The watcher polls the target's PID and decides what to do on death. +Running it inline would mean the foreground `bgo start` command never +returns until the target exits — defeating the point. A detached +subprocess (`start_new_session=True`) survives shell logouts and +keeps polling. + +## Where is state stored? + +`~/.bgo/procs/.json` for state files and +`~/.bgo/logs/.{out,err,watcher}.log` for logs. See +[Architecture](architecture.md#storage-layout). + +## How do I run bgo at system boot, not user login? + +On Linux, enable systemd lingering for your user: + +```bash +loginctl enable-linger $USER +``` + +Then `bgo autostart install` (which targets `default.target`) will +fire at boot rather than at first login. macOS LaunchAgents already +run at login by default; for boot-time start use a LaunchDaemon +instead (system-scope, not user-scope — out of bgo's scope). + +## Does `bgo` survive a reboot? + +Procs running at shutdown time are recorded with `status: running`. +After reboot, `bgo resurrect` walks the state files and respawns +each one. Combine with `bgo autostart install` to make this +automatic. + +## Why doesn't my tray icon appear on GNOME Wayland? + +GNOME doesn't ship a system-tray host. Install the `AppIndicator and +KStatusNotifierItem Support` shell extension once — see +[Tray icon § GNOME Wayland prerequisite](tray.md#gnome-wayland-prerequisite). + +## Why is the tray icon a gear with a colored dot? + +It's a status indicator: green = at least one proc running and none +errored, red = something errored, gray = nothing running. The shape +(gear) communicates "service/process manager"; the color communicates +state at a glance. See [Tray icon § What you see](tray.md#what-you-see). + +## Open-logs doesn't open anything on Linux + +Make sure at least one of these is on `$PATH`: `kitty`, `alacritty`, +`wezterm`, `foot`, `ghostty`, `gnome-terminal`, `konsole`, +`xfce4-terminal`, `tilix`, `xterm`. Or set `BGO_TERMINAL` explicitly: + +```bash +export BGO_TERMINAL='kitty --' +``` + +## Can I use `bgo` over plain SSH? + +Yes, for the CLI side (start/stop/status/logs). The tray and +notifications need a graphical session — they'll silently no-op or +print a "no system tray available" hint when run over SSH. + +## Where does `bgo logs ` get its formatting? + +`bgo logs` shells out to `tail` for the follow mode and reads the +file directly for static views. Output is the raw bytes that the +target wrote — bgo doesn't reformat them. + +## Does `bgo` work on Windows? + +No. Zombie detection and process-group kills use POSIX-specific +APIs (`os.killpg`, `/proc/`, `ps -o stat=`). Linux and macOS only. + +## I get `Permission denied killing PID …` + +The proc is running as a different user. Run `bgo stop ` as +that user, or with `sudo` (not recommended — sudo'd bgo writes state +files under root's home, not yours). + +## How do I debug the watcher? + +Watcher events are logged to `~/.bgo/logs/.watcher.log`. View +with: + +```bash +bgo logs --watcher +bgo logs --watcher -f # live tail +``` + +## How do I uninstall everything bgo created? + +```bash +bgo autostart uninstall +bgo autostart uninstall --tray +rm -rf ~/.bgo # state + logs +uv tool uninstall bgo-cli # the binary itself +# or: pipx uninstall bgo-cli / pip uninstall bgo-cli +``` diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..4023e24 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,80 @@ +# Installation + +bgo is a single-file Python 3.10+ script with zero runtime +dependencies. Several install paths exist; pick the one that matches +how you usually install Python CLIs. + +## Recommended: `uv` + +[uv](https://docs.astral.sh/uv/) installs bgo into an isolated +environment and links the `bgo` command onto your `$PATH`. No global +Python pollution, no virtualenv to manage manually. + +```bash +uv tool install bgo-cli +uv tool upgrade bgo-cli +uv tool uninstall bgo-cli +``` + +To pre-include the optional [tray](tray.md) extra: + +```bash +uv tool install bgo-cli --with PySide6 +``` + +## pipx + +```bash +pipx install bgo-cli +pipx inject bgo-cli PySide6 # for tray (optional) +pipx upgrade bgo-cli +pipx uninstall bgo-cli +``` + +## Plain pip + +```bash +pip install bgo-cli # global / current env +pip install 'bgo-cli[tray]' # with tray extra +pip install --user bgo-cli # user site-packages +``` + +## install.sh (no PyPI) + +The `install.sh` shipped in the repo copies the single-file `bgo` +script to a target directory. No build, no Python packaging. + +```bash +./install.sh # /usr/local/bin (needs sudo) +./install.sh --local # ~/.local/bin (no sudo) +./install.sh --help # --force / --uninstall +``` + +## Fully manual + +Because `bgo` is a single executable Python file: + +```bash +cp bgo ~/.local/bin/ # or any PATH dir +ln -s "$(pwd)/bgo" ~/.local/bin/bgo # symlink to a checkout +``` + +## Verify + +```bash +bgo --help +bgo ls # no procs registered yet +``` + +## Requirements + +- Python 3.10+ +- Linux or macOS (zombie detection is platform-aware: `/proc` on + Linux, `ps -o stat=` on macOS) +- Optional `[tray]` extra: PySide6 (Qt for Python, LGPL) + +## Next + +- [Commands](commands.md) — what to actually run. +- [Watch mode](watch-mode.md) — auto-restart on crash. +- [Tray icon](tray.md) — the optional GUI. diff --git a/docs/notifications.md b/docs/notifications.md new file mode 100644 index 0000000..d7b468f --- /dev/null +++ b/docs/notifications.md @@ -0,0 +1,55 @@ +# Desktop notifications + +bgo fires a desktop notification when a watched process transitions +into the `errored` state — fast-crash budget exhausted, restart +failed, or `--on-fast-crash stop` triggered. The watcher loop is +unaffected by notification failures; notifications are best-effort. + +## Backends + +Zero new Python dependencies — we shell out to platform-native +binaries, in priority order: + +| Order | Binary | Platform | +|---|---|---| +| 1 | `$BGO_NOTIFY_CMD` (override) | any | +| 2 | `notify-send` (libnotify) | Linux | +| 3 | `osascript` (AppleScript) | macOS | +| 4 | `terminal-notifier` (brew) | macOS fallback | + +If none are reachable, notifications silently no-op. + +## Gating + +| Env | Values | Default | Effect | +|---|---|---|---| +| `BGO_NOTIFY` | `off`, `errors`, `all` | `errors` | What level fires | +| `BGO_NOTIFY_CMD` | argv template with `{title}` / `{body}` | — | Bypass backend detection | + +`BGO_NOTIFY_CMD` is parsed with `shlex.split` so quoted values group +correctly. Example: + +```bash +export BGO_NOTIFY_CMD='/usr/local/bin/my-notifier --title "{title}" --body "{body}"' +``` + +## When notifications fire + +- Watcher exhausts backoff retries → `errored`. +- Watcher restart fails (command not found, permission denied, etc). +- `--on-fast-crash stop` mode hits its first fast-crash. + +Manual `bgo stop` and clean exits do **not** fire notifications. +That's deliberate — they're for incidents, not regular ops. + +## Testing + +Verify your backend is reachable: + +```bash +BGO_NOTIFY=all python3 -c "from bgo_cli._notify import notify; \ + print(notify('bgo test', 'hello', 'info'))" +``` + +Should print `True` and pop a notification. If `False`, set +`BGO_NOTIFY_CMD` or install `notify-send` / `terminal-notifier`. diff --git a/docs/tray.md b/docs/tray.md new file mode 100644 index 0000000..26b760b --- /dev/null +++ b/docs/tray.md @@ -0,0 +1,133 @@ +# Tray icon (optional) + +`bgo tray` runs a system-tray icon — a gear with a colored status dot +— that lists every registered proc and exposes one-click actions for +each. The menu rebuilds from `~/.bgo/procs/*.json` every few seconds, +so it always reflects current state. + +## What you see + +**Icon dot color** (status of all registered procs combined): + +| Dot | Meaning | +|---|---| +| 🟢 green | at least one proc running, none errored | +| 🔴 red | any proc is errored | +| ⚫ gray | empty or all stopped | + +**Menu glyph** (per-proc, in submenu labels): + +| Glyph | Meaning | +|---|---| +| ● | online (running, not errored) | +| ○ | stopped | +| ⚠ | errored | + +**Per-proc submenu**: + +- **Restart** (when online) or **Start** (when stopped) — both route + to `bgo restart ` under the hood, because `bgo start` without + a command is rejected by the CLI; `restart` respawns with the + stored command. +- **Stop** +- **Open logs** — spawns `bgo logs -f` in a new terminal + window (live tail with formatting), not the raw log file in an + editor. + +**Global menu entries**: + +- Resurrect all +- Refresh now +- Quit + +## Click activation + +- **Right-click** opens the menu (always; handled by Qt/host). +- **Left-click** and **middle-click** open the same menu, via Qt's + `activated(Trigger / MiddleClick)` signal. Most hosts honor this; + some KDE Plasma setups bind a custom left-click action that + swallows it — in that case right-click still works. +- Set `BGO_TRAY_DEBUG=1` to log the activation reason to stderr. + +## Open-logs terminal probe + +Linux probes a curated list, picking the first one on `$PATH`: + +``` +kitty → alacritty → wezterm → foot → ghostty → +gnome-terminal → konsole → xfce4-terminal → tilix → xterm +``` + +macOS uses AppleScript to spawn Terminal.app. `BGO_TERMINAL=iterm` +switches macOS to iTerm2. For any other emulator: + +```bash +export BGO_TERMINAL='kitty --' +export BGO_TERMINAL='alacritty -e' +export BGO_TERMINAL='weirdterm' # defaults to -e exec flag +``` + +## Platform support + +PySide6 (Qt for Python, LGPL) speaks the StatusNotifierItem (SNI) +protocol natively, so one library covers every target: + +| Platform | What happens | +|---|---| +| macOS | Native `NSStatusItem` in the menu bar | +| KDE Plasma 6 (Wayland or X11) | SNI, no setup | +| Hyprland / sway + waybar | SNI via waybar's `tray` module | +| GNOME Wayland | Needs the AppIndicator shell extension | + +### GNOME Wayland prerequisite + +GNOME ships no system-tray host of its own. Install the +"AppIndicator and KStatusNotifierItem Support" shell extension once: + +```bash +sudo dnf install gnome-shell-extension-appindicator # Fedora +sudo apt install gnome-shell-extension-appindicator # Debian/Ubuntu +gnome-extensions enable appindicatorsupport@rgcjonas.gmail.com +``` + +Log out and back in. KDE / Hyprland / macOS users do not need this. + +## Install + +The tray UI uses PySide6. It's shipped as an optional extra to keep +the core install zero-dep: + +```bash +uv tool install bgo-cli --with PySide6 # uv (recommended) +pipx inject bgo-cli PySide6 # pipx +pip install 'bgo-cli[tray]' # pip +``` + +If you forget, the first run of `bgo tray` detects your installer +(uv tool / pipx / pip) and offers to inject the dep for you. Skip +the confirmation with `--auto-install` or `BGO_TRAY_AUTOINSTALL=1`. + +## Run the tray in the background + +The tray is just another command — wrap it under `bgo`: + +```bash +bgo start -w bgotray -- bgo tray # -w auto-restarts on crash +``` + +Or, simpler, install it as a login autostart entry: + +```bash +bgo autostart install --tray # tray starts on every login +``` + +See [Login autostart](autostart.md) for details. + +## Tuning + +| Flag / env | Default | Effect | +|---|---|---| +| `--poll N`, `BGO_TRAY_POLL` | `3` | Snapshot refresh interval (seconds) | +| `--auto-install`, `BGO_TRAY_AUTOINSTALL=1` | off | Skip the install prompt | +| `BGO_TERMINAL` | (auto-probe) | Terminal emulator for Open-logs | +| `BGO_TRAY_DEBUG=1` | off | Log activation reasons to stderr | diff --git a/docs/watch-mode.md b/docs/watch-mode.md new file mode 100644 index 0000000..b6a3b94 --- /dev/null +++ b/docs/watch-mode.md @@ -0,0 +1,58 @@ +# Watch mode + +Watch mode runs a *sidecar* process per watched proc — a detached +Python subprocess that polls the target and restarts it on crash. +State (restart count, error reason, last stderr tail) is recorded in +the proc JSON and surfaced via `bgo status`. + +## Quick start + +```bash +bgo start -w myapi -- node server.js # start with watcher +bgo -w myapi node server.js # direct-mode variant +bgo watch myapi # attach to a running proc +bgo watch myapi --interval 5 --min-uptime 3 --on-fast-crash backoff +bgo unwatch myapi # detach; proc keeps running +bgo logs myapi --watcher # inspect watcher events +``` + +## Fast-crash policy + +If a process dies before `--min-uptime` (default 2s) it's a *fast +crash*. Reaction depends on `--on-fast-crash`: + +| Mode | Behavior | +|---|---| +| `backoff` (default) | Wait 2s, retry. Then 4s, then 8s. After 4 consecutive fast-crashes, mark `errored` and exit watcher. | +| `stop` | Mark `errored` on the first fast-crash. | +| `retry` | Retry indefinitely, capped at 8s backoff. | + +When a proc enters `errored`: + +- WATCH column shows `⚠ errored` (or `[!] errored` in plain). +- Status footer summarises errored procs and hints at recovery commands. +- `bgo status ` detail shows the error reason and last stderr tail. +- A desktop notification fires (see [Notifications](notifications.md)). +- `bgo restart ` clears the errored flag and re-spawns the + watcher. Restart counter is **preserved** by default — use + `--reset-counters` to zero it. + +## Tunables + +| Flag | Default | Notes | +|---|---|---| +| `--interval N` | 3 | Poll interval after the initial uptime window | +| `--min-uptime N` | 2 | Crash threshold; sub-window polled at high frequency | +| `--on-fast-crash MODE` | `backoff` | One of `backoff`, `stop`, `retry` | +| `--reset` | off | `bgo watch` only — reset prior watch config to defaults | + +## Internals + +The watcher is invoked as `bgo __watcher__ ` and runs the loop +in `bgo_cli._watcher.cmd_watcher_loop`. It polls every `interval` +seconds, computes uptime from the proc's `started_at`, and respawns +the target in place when it dies. The early-check phase polls at +0.2s during the first `min_uptime` window so fast-crashes are caught +with accurate uptime readings even when `interval > min_uptime`. + +See [Architecture](architecture.md) for the full module layout. From 554cd4d7192ee5322dcbfcd6a17684b799fd2f47 Mon Sep 17 00:00:00 2001 From: tcsenpai Date: Tue, 19 May 2026 15:04:26 +0200 Subject: [PATCH 14/16] fix(tray): handle SIGINT/SIGTERM so Ctrl+C exits Qt's app.exec() blocks in a native select() loop, so Python signal handlers never run. Install handlers that call app.quit() and add a 200ms no-op QTimer to wake the interpreter often enough to deliver pending signals. --- src/bgo_cli/_tray.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/bgo_cli/_tray.py b/src/bgo_cli/_tray.py index f2d233e..8bd8dfa 100644 --- a/src/bgo_cli/_tray.py +++ b/src/bgo_cli/_tray.py @@ -424,6 +424,8 @@ def _run_tray(poll_seconds: int) -> int: # pragma: no cover — needs Qt else in the module is framework-agnostic and unit-tested without Qt installed. """ + import signal + try: from PySide6.QtCore import QByteArray, QTimer from PySide6.QtGui import QAction, QIcon, QPixmap @@ -587,6 +589,21 @@ def on_activated(reason: "QSystemTrayIcon.ActivationReason") -> None: timer.start() held.append(timer) + # SIGINT (Ctrl+C) handling. Qt's C++ event loop blocks in select() + # and never returns to the Python interpreter, so a plain + # signal.signal() handler would never fire. Two fixes together: + # 1. Restore the default Python SIGINT handler that calls app.quit + # via Qt's signal-safe path. + # 2. Run a no-op QTimer every 200ms so the interpreter wakes up + # often enough to deliver pending signals to Python handlers. + signal.signal(signal.SIGINT, lambda *_: app.quit()) + signal.signal(signal.SIGTERM, lambda *_: app.quit()) + sigwake = QTimer() + sigwake.setInterval(200) + sigwake.timeout.connect(lambda: None) + sigwake.start() + held.append(sigwake) + return int(app.exec()) From 5448ab473d3c79bab4d7368630381b84c5590538 Mon Sep 17 00:00:00 2001 From: tcsenpai Date: Tue, 19 May 2026 15:10:54 +0200 Subject: [PATCH 15/16] fix(ci): install package + address CodeRabbit feedback CI: - workflow now installs the package (`pip install -e .[dev]`) so test modules can `import bgo_cli` without a sys.path hack on disk - conftest.py also inserts `src/` at collection time as a belt-and- braces fallback for local pytest runs from the repo root Quality: - _state.save_proc: unique tmp file via tempfile.mkstemp; previous shared `*.tmp` path was racy under concurrent writers - _state.load_proc / load_all_procs: reject non-mapping JSON so a corrupt list/string can't crash callers that do `.get(...)` - _proc.get_process_info_batch: use POSIX-portable `pid=,%cpu=,...` header suppression; `--no-headers` is GNU-only and broke macOS ps - _term.color: unknown names now return the text untouched (was appending a stray reset code, violating the documented contract) - _term.truncate: clamp at narrow widths so the 3-char ellipsis can no longer overflow into width<3 columns - _watcher: open log handles get closed via `finally`, so repeated spawn failures don't leak FDs - _autostart.uninstall: propagate launchctl unload errors instead of silently deleting the plist and orphaning the agent - _tray_install.ensure_installed: docstring referenced pystray/PIL (leftover from pre-PySide6 migration) CLI: - `autostart status --tray` is now wired through (subparser flag + handler), matching the help text - `cmd_tray` re-exec forwards `sys.argv[1:]` so `--poll` survives Docs: - replace `(#)` placeholder links in architecture.md / contributing.md with real issue URLs --- .github/workflows/test.yml | 6 ++++-- bgo | 22 +++++++++++++++++---- docs/architecture.md | 7 ++++--- docs/contributing.md | 6 +++--- src/bgo_cli/_autostart.py | 8 ++++++-- src/bgo_cli/_proc.py | 5 ++++- src/bgo_cli/_state.py | 38 ++++++++++++++++++++++++++---------- src/bgo_cli/_term.py | 23 +++++++++++++++++++--- src/bgo_cli/_tray_install.py | 4 ++-- src/bgo_cli/_watcher.py | 14 ++++++++++--- tests/conftest.py | 8 ++++++++ 11 files changed, 108 insertions(+), 33 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ca22fd5..e5d91c8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,8 +23,10 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Install pytest - run: python -m pip install --upgrade pip pytest + - name: Install package + pytest + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" - name: Run tests run: python -m pytest tests/ -v diff --git a/bgo b/bgo index bbeadeb..82eb8b2 100755 --- a/bgo +++ b/bgo @@ -1229,14 +1229,23 @@ def cmd_autostart(args: argparse.Namespace) -> int: if action == "status": s = _autostart.status() + tray_only = getattr(args, "tray", False) if getattr(args, "json", False): - print(json.dumps(s, indent=2)) + if tray_only: + print(json.dumps({ + "backend": s.get("backend"), + "tray": s.get("tray"), + "tray_path": s.get("tray_path"), + }, indent=2)) + else: + print(json.dumps(s, indent=2)) return 0 if s["backend"] is None: print(f"{color('yellow', '⚠')} autostart not supported on this platform") return 1 print(f"backend: {s['backend']}") - for key in ("resurrect", "tray"): + keys = ("tray",) if tray_only else ("resurrect", "tray") + for key in keys: mark = color("green", "✓") if s[key] else color("gray", "·") path = s.get(f"{key}_path") or "(not installed)" print(f"{mark} {key:<10} {path}") @@ -1292,9 +1301,10 @@ def cmd_tray(args: argparse.Namespace) -> int: # shutil.which("bgo") over sys.argv[0] because argv[0] may be # the bare script path ("./bgo") or "python", neither of which # have the new PySide6 on their import path after a uv/pipx - # injection completed. + # injection completed. Pass the original argv[1:] through so + # flags like --poll survive the re-exec. bgo_bin = shutil.which("bgo") or sys.argv[0] - os.execvp(bgo_bin, [bgo_bin, "tray"]) + os.execvp(bgo_bin, [bgo_bin, *sys.argv[1:]]) return 0 # unreachable return _tray.run(poll_seconds=getattr(args, "poll", None)) @@ -1620,6 +1630,10 @@ Watch mode (auto-restart on crash): help="Target the tray autostart entry instead of resurrect", ) sp_status = autostart_sub.add_parser("status", help="Show install state") + sp_status.add_argument( + "--tray", action="store_true", + help="Show status for the tray entry only (default: both)", + ) sp_status.add_argument( "--json", action="store_true", help="Machine-readable output", ) diff --git a/docs/architecture.md b/docs/architecture.md index 13d59b0..989e899 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -121,7 +121,8 @@ uv run pytest tests/test_notify.py -v # one module ## Future work - Extract `cmd_*` handlers from the root script into - `bgo_cli._commands` (tracked in [fu #6 partial - completion](#)). Status rendering, the interactive multiselect, - and the argparse front-end can follow. + `bgo_cli._commands` (tracked in + [#6 (partial)](https://github.com/tcsenpai/bgo/issues/6)). + Status rendering, the interactive multiselect, and the argparse + front-end can follow. - mypy/pyright strict pass over the script (modules already type-clean). diff --git a/docs/contributing.md b/docs/contributing.md index 8cc4663..0c2bf2b 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -51,9 +51,9 @@ No required linter is wired into CI yet. The newer modules new code at that bar. The remaining cmd_* handlers in the root `bgo` script were typed in -the [fu #5 sweep](#) — every `cmd_*` signature is -`(args: argparse.Namespace) -> int` with docstrings describing -inputs / behavior / exit codes. +the [#5 sweep](https://github.com/tcsenpai/bgo/issues/5) — every +`cmd_*` signature is `(args: argparse.Namespace) -> int` with +docstrings describing inputs / behavior / exit codes. ## Project structure diff --git a/src/bgo_cli/_autostart.py b/src/bgo_cli/_autostart.py index eb86400..3980cfd 100644 --- a/src/bgo_cli/_autostart.py +++ b/src/bgo_cli/_autostart.py @@ -320,9 +320,13 @@ def uninstall(target: Target) -> tuple[bool, str]: path.unlink(missing_ok=True) return True, "" - # launchd + # launchd. If unload fails for a reason other than "already absent", + # leave the plist in place so the user can retry — deleting it would + # orphan a still-loaded agent that's hard to manage afterwards. if path.exists(): - _launchctl_unload(path) + ok, err = _launchctl_unload(path) + if not ok: + return False, err path.unlink(missing_ok=True) return True, "" diff --git a/src/bgo_cli/_proc.py b/src/bgo_cli/_proc.py index f180e0b..58d9e20 100644 --- a/src/bgo_cli/_proc.py +++ b/src/bgo_cli/_proc.py @@ -83,9 +83,12 @@ def get_process_info_batch(pids: list[int]) -> dict[int, dict]: return result_map # ps -p accepts comma-separated pids on both Linux and macOS. pid_arg = ",".join(str(p) for p in pids) + # POSIX-portable header suppression: setting each column's header + # to an empty string (via "key=") tells ps to emit no header line. + # macOS/BSD ps does not support GNU's --no-headers long option. try: result = subprocess.run( - ["ps", "-p", pid_arg, "-o", "pid,%cpu,%mem,etime", "--no-headers"], + ["ps", "-p", pid_arg, "-o", "pid=,%cpu=,%mem=,etime="], capture_output=True, text=True, timeout=4, diff --git a/src/bgo_cli/_state.py b/src/bgo_cli/_state.py index 08abf2e..178ff43 100644 --- a/src/bgo_cli/_state.py +++ b/src/bgo_cli/_state.py @@ -17,6 +17,7 @@ import json import os +import tempfile from datetime import datetime from pathlib import Path @@ -59,28 +60,43 @@ def watcher_log(name: str, msg: str) -> None: def load_proc(name: str) -> dict | None: - """Return parsed state for ``name``, or ``None`` if missing/corrupt.""" + """Return parsed state for ``name``, or ``None`` if missing/corrupt. + + Non-object JSON (lists, strings, numbers) is treated as corrupt + because the rest of the codebase assumes a mapping (``.get(...)``). + """ pf = proc_file(name) if not pf.exists(): return None try: - return json.loads(pf.read_text()) + data = json.loads(pf.read_text()) except (json.JSONDecodeError, OSError): return None + return data if isinstance(data, dict) else None def save_proc(name: str, info: dict) -> None: """Atomically write ``info`` as ``name``'s state file. - Uses a tmp file plus ``os.replace`` so concurrent writers (CLI + - watcher) cannot land a torn JSON. ``os.replace`` is atomic on - POSIX when src and dst share a filesystem, which is true here - (both under ``~/.bgo/procs/``). + Uses a unique tmp file in the same directory plus ``os.replace`` + so concurrent writers (CLI + watcher) cannot stomp on each other's + in-flight tmp file. ``os.replace`` is atomic on POSIX when src and + dst share a filesystem, which is true here (both under + ``~/.bgo/procs/``). """ pf = proc_file(name) - tmp = pf.with_suffix(pf.suffix + ".tmp") - tmp.write_text(json.dumps(info, indent=2)) - os.replace(tmp, pf) + fd, tmp_name = tempfile.mkstemp( + prefix=f"{pf.stem}.", suffix=".tmp", dir=str(pf.parent) + ) + tmp = Path(tmp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(info, f, indent=2) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, pf) + finally: + tmp.unlink(missing_ok=True) def delete_proc(name: str, keep_logs: bool = False) -> None: @@ -99,9 +115,11 @@ def load_all_procs() -> dict[str, dict]: for pf in sorted(PROCS_DIR.glob("*.json")): try: info = json.loads(pf.read_text()) - procs[info.get("name", pf.stem)] = info except (json.JSONDecodeError, OSError): continue + if not isinstance(info, dict): + continue + procs[info.get("name", pf.stem)] = info return procs diff --git a/src/bgo_cli/_term.py b/src/bgo_cli/_term.py index a56fa43..ce50b63 100644 --- a/src/bgo_cli/_term.py +++ b/src/bgo_cli/_term.py @@ -39,10 +39,17 @@ def color(name: str, text: str) -> str: - """Wrap text in color codes, only if stdout is a TTY.""" + """Wrap text in color codes, only if stdout is a TTY. + + Unknown color names return the text unchanged (no reset code + appended), preserving the documented "no wrapping" contract. + """ if not sys.stdout.isatty(): return str(text) - return f"{COLORS.get(name, '')}{text}{COLORS['reset']}" + code = COLORS.get(name) + if not code: + return str(text) + return f"{code}{text}{COLORS['reset']}" def strip_ansi(s: str) -> str: @@ -51,7 +58,17 @@ def strip_ansi(s: str) -> str: def truncate(s: str, width: int) -> str: - """Truncate string to fit in width, accounting for ANSI codes.""" + """Truncate string to fit in width, accounting for ANSI codes. + + Edge cases for narrow columns: + * ``width <= 0`` -> empty string + * ``width <= 3`` -> ``"..."[:width]`` so output never exceeds + ``width`` (the 3-char ellipsis would otherwise overflow). + """ + if width <= 0: + return "" + if width <= 3: + return "..."[:width] plain = strip_ansi(s) if len(plain) > width: return s[: width - 3] + "..." diff --git a/src/bgo_cli/_tray_install.py b/src/bgo_cli/_tray_install.py index 9e322ea..9690c0e 100644 --- a/src/bgo_cli/_tray_install.py +++ b/src/bgo_cli/_tray_install.py @@ -107,10 +107,10 @@ def _installer_available(installer: InstallerKind) -> bool: def ensure_installed(auto: bool = False) -> bool: - """Make sure ``pystray`` + ``PIL`` are importable. Install if not. + """Make sure ``PySide6`` is importable. Install if not. :param auto: Skip the y/N prompt and run the install command. - :returns: ``True`` once both deps import cleanly. ``False`` if + :returns: ``True`` once PySide6 imports cleanly. ``False`` if the user declined or the install command failed. .. important:: diff --git a/src/bgo_cli/_watcher.py b/src/bgo_cli/_watcher.py index 298ffd3..76b8af1 100644 --- a/src/bgo_cli/_watcher.py +++ b/src/bgo_cli/_watcher.py @@ -135,6 +135,7 @@ def _bgo_entrypoint() -> str: def _spawn_watcher(name: str) -> tuple[int | None, int | None]: """Detach a watcher subprocess for ``name``. Returns ``(pid, pgid)``.""" + wlog = None try: wlog = open(watcher_log_path(name), "a") bgo_bin = _bgo_entrypoint() @@ -145,10 +146,12 @@ def _spawn_watcher(name: str) -> tuple[int | None, int | None]: stdin=subprocess.DEVNULL, start_new_session=True, ) - wlog.close() except Exception as e: watcher_log(name, f"failed to spawn watcher: {e}") return None, None + finally: + if wlog is not None: + wlog.close() try: pgid = os.getpgid(proc.pid) except OSError: @@ -198,6 +201,8 @@ def _restart_proc_inplace(info: dict) -> tuple[int | None, int | None, str | Non cwd = info.get("cwd") or os.getcwd() stdout_log = log_path(name, "out") stderr_log = log_path(name, "err") + out_f = None + err_f = None try: out_f = open(stdout_log, "a") err_f = open(stderr_log, "a") @@ -215,14 +220,17 @@ def _restart_proc_inplace(info: dict) -> tuple[int | None, int | None, str | Non start_new_session=True, cwd=cwd, ) - out_f.close() - err_f.close() except FileNotFoundError: return None, None, f"command not found: {command[0]}" except PermissionError: return None, None, f"permission denied: {command[0]}" except Exception as e: return None, None, f"failed to start: {e}" + finally: + if out_f is not None: + out_f.close() + if err_f is not None: + err_f.close() try: pgid = os.getpgid(proc.pid) except OSError: diff --git a/tests/conftest.py b/tests/conftest.py index 29247f1..b4ccc1e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,14 @@ ROOT = Path(__file__).resolve().parent.parent BGO_PATH = ROOT / "bgo" +SRC_PATH = ROOT / "src" + +# Make `from bgo_cli import ...` resolvable during pytest collection, +# before any test module is imported. CI runs `python -m pytest` from +# the repo root without installing the package, so without this the +# test_*.py modules that import bgo_cli fail at collection time. +if str(SRC_PATH) not in sys.path: + sys.path.insert(0, str(SRC_PATH)) def _load_bgo(): From 25bc483d8d1869bc5aea43c57bcb5e3e2d6f06ea Mon Sep 17 00:00:00 2001 From: tcsenpai Date: Tue, 19 May 2026 15:13:38 +0200 Subject: [PATCH 16/16] fix(tray): sort snapshots by stored name; escape AppleScript args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - load_snapshots: sort after parsing so order follows the snapshot's stored `name` field (filename can drift from name after rename) - _open_logs_darwin: escape backslashes + double quotes in the shell-quoted command before interpolating into the AppleScript string literal — paths containing either would otherwise terminate the AppleScript literal early --- src/bgo_cli/_tray.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/bgo_cli/_tray.py b/src/bgo_cli/_tray.py index 8bd8dfa..e861c8c 100644 --- a/src/bgo_cli/_tray.py +++ b/src/bgo_cli/_tray.py @@ -97,15 +97,19 @@ def _load_one(path: Path) -> ProcSnapshot | None: def load_snapshots(procs_dir: Path = PROCS_DIR) -> list[ProcSnapshot]: - """Load every proc state file as a snapshot, sorted by name.""" + """Load every proc state file as a snapshot, sorted by ``name``. + + Sort happens after parsing so the order follows the proc's stored + ``name`` field (which may differ from the on-disk filename). + """ if not procs_dir.exists(): return [] out: list[ProcSnapshot] = [] - for pf in sorted(procs_dir.glob("*.json")): + for pf in procs_dir.glob("*.json"): snap = _load_one(pf) if snap is not None: out.append(snap) - return out + return sorted(out, key=lambda s: s.name) # --- Menu construction --------------------------------------------------- @@ -281,7 +285,14 @@ def _open_logs_darwin(follow_cmd: list[str]) -> None: """ import shlex + # Shell-quote each arg so the spawned terminal's shell parses the + # command back into argv correctly. quoted = " ".join(shlex.quote(arg) for arg in follow_cmd) + # Then escape the result for the AppleScript string layer: any + # backslashes or double quotes inside `quoted` (e.g. from paths + # containing them) would otherwise terminate the AppleScript + # literal early or smuggle in extra AppleScript tokens. + quoted = quoted.replace("\\", "\\\\").replace('"', '\\"') override = (os.environ.get("BGO_TERMINAL") or "").strip().lower() use_iterm = override in ("iterm", "iterm2")