From 4bb9aa413e715e9b4e2e1408ee68f36bbca6ca8a Mon Sep 17 00:00:00 2001 From: Alex Ultra Date: Wed, 17 Jun 2026 01:00:24 +0200 Subject: [PATCH 1/5] feat(rig): add tg_ctl config block + pure plist planning Add the tg_ctl config block (validate + plan) and the pure, effect-free TgCtlPlan that renders the ai.hyperide.tg-ctl.plist LaunchAgent XML byte-exact to the working hand-created file (sort_keys=False preserves the insertion order so a re-apply is a true no-op). Default-on, per-machine (GLOBAL layer), macOS-only. Mirrors the tmux block's schema style. boot:null and label:null resolve to their defaults (not bool(None)=False / str(None)="None"). Reviewed via multi-model `review`; findings addressed. Co-Authored-By: Claude Opus 4.8 --- riglib/config.py | 42 ++++++++++ riglib/plan.py | 47 +++++++++++ riglib/tg_ctl.py | 200 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 289 insertions(+) create mode 100644 riglib/tg_ctl.py diff --git a/riglib/config.py b/riglib/config.py index 7bb32c9..454161e 100644 --- a/riglib/config.py +++ b/riglib/config.py @@ -40,6 +40,7 @@ "github", "tmux", "gitignore", + "tg_ctl", } _VALID_CATEGORIES = {"skills", "agent_hooks", "git_hooks", "ci", "mcp"} _VALID_ON_CONFLICT = {"skip", "overwrite", "backup"} @@ -263,6 +264,7 @@ def validate(data: dict[str, Any]) -> None: _validate_github(data.get("github", {})) _validate_tmux(data.get("tmux", {})) _validate_gitignore(data.get("gitignore", {})) + _validate_tg_ctl(data.get("tg_ctl", {})) def _validate_ci(ci: dict[str, Any]) -> None: @@ -715,3 +717,43 @@ def _validate_tmux(t: dict[str, Any]) -> None: "tmux.login_shell.shell must be an absolute path to the shell BINARY with no " f"arguments (rig adds `-l`), or empty to use $SHELL — got {shell!r}" ) + + +# The keys the tg_ctl block accepts. Listed once so the validator rejects a typo (fail-closed, +# consistent with every other block). +_TG_CTL_KEYS = { + "enabled", + "boot", + "label", + "bun_path", + "tg_ctl_path", + "config_dir", +} + + +def _validate_tg_ctl(t: dict[str, Any]) -> None: + """Validate the ``tg_ctl`` block — rig-managed tg-ctl inbound-daemon LaunchAgent. + + This is a per-MACHINE concern (one inbound Telegram control daemon per machine), so it + belongs in the GLOBAL layer (``~/.config/rig/config.yaml``), like ``harness``/``tmux``/ + ``git_hooks`` — NOT a committed repo ``rig.yaml``. Default **ON**: an EMPTY/absent block + still provisions the daemon (a present block with ``enabled`` not false opts in). Fail-closed, + consistent with every other block, on: a non-mapping block, an unknown key (typo guard), a + non-bool ``enabled``/``boot``, and a non-string ``label``/``bun_path``/``tg_ctl_path``/ + ``config_dir``. + """ + if not isinstance(t, dict): + raise ConfigError("tg_ctl must be a mapping") + if not t: + return + unknown = set(t) - _TG_CTL_KEYS + if unknown: + raise ConfigError(f"unknown tg_ctl key(s): {', '.join(sorted(unknown))}") + for boolkey in ("enabled", "boot"): + value = t.get(boolkey) + if value is not None and not isinstance(value, bool): + raise ConfigError(f"tg_ctl.{boolkey} must be a bool, got {value!r}") + for strkey in ("label", "bun_path", "tg_ctl_path", "config_dir"): + value = t.get(strkey) + if value is not None and not isinstance(value, str): + raise ConfigError(f"tg_ctl.{strkey} must be a string, got {value!r}") diff --git a/riglib/plan.py b/riglib/plan.py index fde47c4..db8e778 100644 --- a/riglib/plan.py +++ b/riglib/plan.py @@ -527,6 +527,9 @@ def build(config: LoadedConfig, catalog: Catalog, *, project_type: str = "unknow # ── gitignore (rig-managed block in the GLOBAL git excludes file) ────────────── _build_global_excludes(config, plan) + # ── tg_ctl (rig-managed tg-ctl inbound daemon LaunchAgent) ───────────────────── + _build_tg_ctl(config, plan) + return plan @@ -922,3 +925,47 @@ def _build_tmux(config: LoadedConfig, plan: InstallPlan) -> None: }, ) ) + + +def _build_tg_ctl(config: LoadedConfig, plan: InstallPlan) -> None: + """Plan the rig-managed tg-ctl inbound-daemon LaunchAgent, unless ``enabled: false``. + + Default **ON** (like ``agents_md``/``github``): an ABSENT or empty ``tg_ctl:`` block still + provisions the daemon, so ``rig init`` on a clean machine sets it up with no config at all. + Only ``enabled: false`` opts out. This is a per-MACHINE concern (one inbound Telegram + control daemon per machine), so the block belongs in the GLOBAL layer + (``~/.config/rig/config.yaml``) — but it cascades into the merged config the same way. + + Unlike tmux, the tg-ctl artifact paths are HOME-anchored per-machine (not repo-relative): + the runner resolves them against ``Path.home()`` at apply time (so a committed rig.yaml stays + portable and never anchors ~/.files/bin to a repo root). The action just carries the raw + config knobs; the bun path is discovered at apply time. + """ + from .tg_ctl import DEFAULT_BOOT_LABEL + + # An ABSENT key (None) defaults to provisioning (default-on). A PRESENT block (validate() has + # already guaranteed it is a mapping) opts in unless `enabled: false`. + t = config.data.get("tg_ctl") or {} + if t.get("enabled") is False: + return + + plan.actions.append( + Action( + kind="provision_tg_ctl", + category="tg_ctl", + item="boot", + source=config.repo_root, # no carrier; rig generates the plist + # target is the launchd LABEL (not a filesystem path) — the runner resolves the real + # ~/Library/LaunchAgents/