Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions docs/config-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -1515,6 +1515,11 @@ tmux:
position: top # "top" or "bottom"
format: "#{session_name} #{window_index}:#{window_name}#{window_flags}"
clear_status_right: true # separately clear tmux's default clock+date status-right
focus_events:
enabled: true # set -g focus-events on — report terminal focus in/out
# events to programs running inside panes (some editors/
# tools need this). A plain terminal-capability toggle, no
# interaction with the resurrect/continuum/tpm ordering.
```

| Key | Type | Default | Meaning |
Expand Down Expand Up @@ -1543,6 +1548,7 @@ tmux:
| `pane_titles.clear_status_right` | bool | `true` | when `pane_titles.enabled` is also true, additionally clear tmux's built-in `status-right` default — which is otherwise a clock+date (`%H:%M %d-%b-%y`), wasted space once the pane title carries the session/window context. A SEPARATE toggle from `enabled` (not independent of it): set this `false` to keep the border title while preserving an existing custom `status-right`. |
| `login_shell.enabled` | bool | `true` | set a **login-shell** `default-command` so restored panes source `~/.zprofile`/PATH (resurrect otherwise restores a non-login shell with a broken env) |
| `login_shell.shell` | str | `""` | login shell path. `""` resolves the user's `$SHELL` at apply (falling back to `/bin/zsh` then `/bin/sh`); a non-empty override **must be an absolute path** to the shell binary (a relative name or a command-with-args is rejected, so it can't silently produce a broken `default-command`) and is used verbatim. The path is **baked at generation** — NOT a tmux `${SHELL}` reference, because tmux rejects `${VAR:-default}` and would abort the whole config |
| `focus_events.enabled` | bool | `true` | `set -g focus-events` on/off — tmux reports terminal focus in/out events to programs running inside panes (needed by editors/tools that react to focus). A plain capability toggle: it carries none of the resurrect/continuum/tpm ordering constraints below (it's placed first in the generated file purely for readability, not because position is load-bearing here). Like every other modeled boolean, `false` emits an explicit `set -g focus-events off` rather than omitting the line. |

**Apply mechanism — import-preferred, managed-block fallback.**

Expand Down Expand Up @@ -1689,6 +1695,87 @@ boot-from-cold path can only be fully proven by an actual reboot.

---

## `env`

Provisions **rig-managed shell environment variables** — a var like `COLORTERM` that must be
visible to **every** shell invocation on the machine, not just an interactive login shell: a
mosh/SSH non-interactive command shell (`ssh host 'some-command'`), a cron/launchd job, or
`zsh -l -c '...'`. This is **GLOBAL config** (the shell startup file rig sources into is
HOME-anchored, not repo-relative) — it belongs in `~/.config/rig/config.yaml`.

Mirrors `tmux`'s "own a generated file, splice one import line" shape — rig owns
`<generated_dir>/rig.env.sh` (wholesale rewrite each apply, one `export KEY=value` per `vars`
entry, sorted by key) and ensures **one** `source '<generated file>'` line is present in
`rc_path` — every other line in that file is left untouched. Unlike `tmux` there is no dual
import/block mode and no neutralization of unrelated inline content: a plain exported var has
no equivalent of tmux's `@plugin`/`@continuum-*` declarations that need detecting and
superseding, so the splice is a simple idempotent "ensure this one line is present" append.

**Position-tolerant — deliberately NOT end-anchored like `tmux`.** `tmux`'s import line is
always re-appended at the very END on every apply, dropping any existing copy first, because
tmux's ordering guarantee genuinely depends on position (continuum's `run-shell` init must be
LAST). A plain exported var has no such hazard, so `env` takes the opposite default: if rig's
CURRENT import line already appears **anywhere** in `rc_path` — including above the user's own
exports, e.g. so *their* values win — that line's POSITION is left exactly where the user put
it, and every OTHER line is untouched. This is NOT unconditionally "byte-for-byte untouched",
though: a coexisting STALE line (an old `generated_dir`, left over from before a config change)
is always dropped even when the current line is already present and correctly placed elsewhere
— position-tolerance for the CURRENT line must not become an excuse to leave an orphaned old
`rig.env.sh` silently sourced forever. Only when the current line is truly absent everywhere
(first apply, or only that stale copy existed) does `env` append the current line once, at the
end. `rig status` shares this exact predicate, so it can never disagree with what `rig apply`
would do.

**No teardown on `enabled: false`.** Like every other rig-managed artifact ("`rig apply` NEVER
deletes on-disk extras"), turning `env` off stops rig from RECONCILING `rig.env.sh` and the
import line, but does not remove them — the vars keep applying to every shell until you delete
`<generated_dir>/rig.env.sh` and the `source` line yourself. `rig status` DOES surface this one
specifically — unlike `tmux`, which has no equivalent — checking BOTH halves independently (a
leftover `rig.env.sh`, and separately a leftover `source` line in `rc_path`, since either can
survive without the other: a hand-deleted `rig.env.sh` with the source line still present makes
every shell invocation on the machine print a "no such file or directory" error at startup).
**This coverage fires ONLY for an explicit `env: { enabled: false }`** — REMOVING the `env:`
block entirely (the more natural way to "turn a feature off", and the one actually described
above as "the real leave-shell-env-vars-alone case") runs no scan at all, so the same
still-active artifacts would report as in sync in that case. Only `gitignore`/`git_hooks`
get an equivalent "still installed but now disabled" check elsewhere in rig; extending any of
them to also cover full block-removal is a known, tracked gap, not implemented here.

**Why `~/.zshenv` by default, not `~/.zshrc`/`~/.zprofile`.** zsh's startup order makes
`~/.zshenv` the only file sourced **unconditionally** in every mode — login or not, interactive
or not (`~/.zshrc` is interactive-only; `~/.zprofile`/`~/.zlogin` are login-only). A var that
must reach `zsh -l -c '...'` (a login but **non-interactive** shell — `-c` means it never
becomes interactive, so `.zshrc` is never sourced) has to live in `.zshenv` or `.zprofile`;
`.zshenv` is the more universal of the two (it also reaches plain non-login non-interactive
invocations). Same reasoning as `tmux.login_shell` treating `~/.zprofile` as the login-env
source of truth, one file more universal.

A PRESENT `env:` block opts in (mirrors `tmux`: even an explicit empty mapping `env: {}` is
accepted, yielding a harmless header-only generated file); only an ABSENT key or
`enabled: false` is a no-op.

```yaml
env:
enabled: true # provision the rig-managed shell env vars (opt-in)
rc_path: ~/.zshenv # the shell startup file rig sources the generated file from
generated_dir: ~/.config/rig/env # where rig writes rig.env.sh
vars:
COLORTERM: truecolor # exported KEY=value pairs
```

| Key | Type | Default | Meaning |
| --- | --- | --- | --- |
| `enabled` | bool | — (see below) | opts OUT when explicitly `false`. There is no fixed default for this key alone — a PRESENT `env:` block with `enabled` unset already opts IN (mirrors `tmux`); it is the BLOCK's presence, not this key's own default, that decides. An entirely ABSENT `env:` block is the real "leave shell env vars alone" case. |
| `rc_path` | path | `~/.zshenv` | the shell startup file rig sources the generated env file from |
| `generated_dir` | path | `~/.config/rig/env` | where rig writes `rig.env.sh` |
| `vars` | map[str,str] | `{}` | exported `KEY=value` pairs. Keys must be valid POSIX shell identifiers (`[A-Za-z_][A-Za-z0-9_]*` — they are interpolated UNQUOTED into `export {key}=...`, a file sourced by every shell invocation on the machine, so an invalid key is rejected at validate time rather than reaching that file). Values are shell-quoted at render time, safe for spaces/quotes/`$`/backticks/anything. |

Idempotent: a re-apply that finds both the generated file and the import line already current is
a `skipped` no-op. `rig status` flags drift on the generated file's content and the import line's
presence — never on any other line in `rc_path` (that file is otherwise entirely the user's own).

---

## `gitignore`

Maintains a **rig-managed block** in git's **GLOBAL excludes file** (`core.excludesfile`) so
Expand Down
148 changes: 148 additions & 0 deletions riglib/actions/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -3168,6 +3168,7 @@ def tmux_plan_from_action(action: Action):
login_shell=dict(opts.get("login_shell", {}) or {}),
autosave=dict(opts.get("autosave", {}) or {}),
pane_titles=dict(opts.get("pane_titles", {}) or {}),
focus_events=dict(opts.get("focus_events", {}) or {}),
)


Expand Down Expand Up @@ -6516,6 +6517,152 @@ def _resolve_excludes_target(action: Action) -> tuple[Path, bool, str | None]:
return expand_user_path(xdg_default), True, xdg_default


def env_plan_from_action(action: Action):
"""Rebuild the pure :class:`~riglib.shell_env.ShellEnvPlan` an action describes.

Shared by the install handler and the drift check so both agree on the exact desired
artifacts from the action's options. ``Path.home()`` is the resolved HOME at apply time (a
test monkeypatches it to a tmp HOME). Lazy import keeps the actions package import-light.

UNLIKE ``tmux_plan_from_action``, ``rc_path``/``generated_dir`` are REQUIRED here — a
``KeyError`` for a missing one is a programmer error, not a runtime condition to paper over.
``env`` is a brand-new action kind (unlike ``tmux``, which has genuinely pre-dated some of
its options across real upgrades), so there is no real "action persisted by an older rig"
scenario for a bare-default fallback to serve; ``_build_env`` (``riglib/plan.py``) is the
ONE place that resolves the defaults (including the ``~/.config`` -> ``$XDG_CONFIG_HOME``
special case) and it always writes both keys into ``Action.options`` — so a duplicate
fallback default here would only ever be exercised by a caller NOT going through
``_build_env``, and would then risk resolving differently than the plan builder did (review
finding, discovered via exactly that divergence). Failing loudly beats resolving quietly
wrong.
"""
from ..shell_env import build_shell_env

opts = action.options
return build_shell_env(
repo_home=Path.home(),
rc_path=str(opts["rc_path"]),
generated_dir=str(opts["generated_dir"]),
vars=dict(opts.get("vars", {}) or {}),
)


def _do_provision_env(action: Action, on_conflict: str) -> ActionResult:
"""Generate the rig-managed shell-env-vars file and ensure it is sourced from ``rc_path``.

What it writes:
- ``<generated_dir>/rig.env.sh`` — the rig-owned file (wholesale rewrite; one
``export KEY=value`` line per configured var).
- ``rc_path`` (default ``~/.zshenv``) — POSITION-TOLERANT: if rig's CURRENT import line
already appears anywhere, ``rc_path`` is untouched (even a user-relocated line is left
where they put it); otherwise it is appended at the end, dropping any STALE copy (an
old ``generated_dir``) first. Nothing but rig's own recognizable line is ever touched —
see :func:`riglib.shell_env.desired_rc_text` for the exact predicate, shared verbatim
with the drift check so apply and status can never disagree.

Both writes go through :func:`riglib.actions.fsutil.write_file` — same ``on_conflict``
policy, same backup-on-replace, same error-to-``ActionResult`` conversion for BOTH files.
``rc_path`` is the user's own, possibly irreplaceable file (unlike the freely-regenerable
``rig.env.sh``), so it gets the SAME safety treatment, not less (review finding: an earlier
version wrote it with a bare ``write_text`` — no backup, and only ``OSError`` was caught,
so a non-UTF-8 ``rc_path`` raised ``UnicodeDecodeError`` — a ``ValueError`` — right through
apply uncaught).

Idempotent: a re-apply that finds both artifacts already current is a ``skipped`` no-op.
"""
from ..shell_env import desired_rc_text

plan = env_plan_from_action(action)

changed = False
details: list[str] = []
# conflict-skipped rc_path splice (on_conflict=skip left it untouched though it needed
# reconciling) — surfaced in the result detail but NOT counted as a change (nothing was
# written): unresolved drift, matching tmux's `skipped_conflicts` convention.
unresolved: list[str] = []
backup: Path | None = None

# 1) the generated rig.env.sh (wholesale, idempotent on identical bytes).
#
# `render_env_file()`'s key check should never actually raise — `config.validate` already
# rejects an invalid var key before a plan is ever built — but it is a defense-in-depth
# invariant, not a caller contract every path is guaranteed to have gone through (review),
# so a violation is still reported as a clean ActionResult error rather than an uncaught
# ValueError. Same for the mkdir: a file blocking `generated_dir` (or a permissions issue)
# must not propagate raw either.
try:
plan.generated_dir.mkdir(parents=True, exist_ok=True)
rendered = plan.render_env_file()
except (OSError, ValueError) as exc:
return ActionResult(action, "error", f"env: failed to prepare {plan.generated_file_path}: {exc}")
env_out = fsutil.write_file(plan.generated_file_path, rendered, on_conflict)
if env_out.status == "error":
return ActionResult(action, "error", f"env: {env_out.detail}")
if env_out.backup:
backup = env_out.backup
if env_out.status != "skipped":
changed = True
details.append(f"generated {plan.generated_file_path.name}")
elif not env_out.detail.startswith("identical"):
# `on_conflict=skip` left a HAND-EDITED (or otherwise differing) rig.env.sh untouched —
# the SAME unresolved-conflict class as the rc_path branch below, so it needs the SAME
# surfacing: without this, `changed` would stay False here while the file genuinely
# differs from the desired render, and a run where nothing else changed either would
# report "already current" — asserting currency that isn't true, while `rig status`
# simultaneously (and correctly) reports it as modified (review finding: the rc_path
# side got this disambiguation, the generated-file side did not).
unresolved.append(
f"{plan.generated_file_path.name} differs and on_conflict=skip — NOT regenerated"
)

# 2) ensure the import line in rc_path. The read (to compute `desired`) can fail the same
# ways the write can — a vanished/unreadable file, or non-UTF-8 content (`UnicodeDecodeError`
# is a `ValueError`, not an `OSError` — both are caught so neither propagates raw).
try:
existing = plan.rc_path.read_text(encoding="utf-8") if plan.rc_path.is_file() else ""
except (OSError, UnicodeDecodeError) as exc:
return ActionResult(action, "error", f"env: failed to read {plan.rc_path}: {exc}", backup)

desired = desired_rc_text(existing, plan)
if desired != existing:
try:
plan.rc_path.parent.mkdir(parents=True, exist_ok=True)
except OSError as exc:
return ActionResult(
action, "error", f"env: failed to create {plan.rc_path.parent}: {exc}", backup
)
rc_out = fsutil.write_file(plan.rc_path, desired, on_conflict)
if rc_out.status == "error":
return ActionResult(action, "error", f"env: {rc_out.detail}", backup)
if rc_out.backup:
# `ActionResult.backup` is a single slot; if BOTH artifacts got backed up this run,
# rc_path's backup deliberately wins (overwrites the generated-file one) — rc_path
# is the irreplaceable file, the generated one is trivially regenerable from config.
# Both backups still exist on disk either way; only the headline pointer is singular.
backup = rc_out.backup
if rc_out.status != "skipped":
changed = True
details.append(f"added import line to {plan.rc_path}")
elif not rc_out.detail.startswith("identical"):
# `on_conflict=skip` left rc_path UNCHANGED even though `desired != existing` — the
# import line was NOT added, so `rig.env.sh` exists but nothing sources it yet: the
# vars silently never take effect. Must not be indistinguishable from a full,
# successful "updated"/"skipped: already current" (review finding — SURFACE it
# explicitly; `changed` correctly stays False here since nothing was WRITTEN, but
# the unresolved-conflict text must still reach the caller, not be discarded by the
# `not changed` branch below returning a hardcoded "already current").
unresolved.append(
f"import line NOT added to {plan.rc_path} (on_conflict=skip) — the "
"generated vars are not yet sourced; re-run with backup/overwrite to wire it"
)

if not changed:
if unresolved:
return ActionResult(action, "skipped", "env: " + "; ".join(unresolved), backup)
return ActionResult(action, "skipped", "env: already current", backup)
return ActionResult(action, "updated", "env: " + "; ".join(details + unresolved), backup)


def _do_provision_global_excludes(action: Action, on_conflict: str) -> ActionResult:
"""Provision/reconcile rig's managed block in the GLOBAL git ``core.excludesfile``.

Expand Down Expand Up @@ -6664,6 +6811,7 @@ def _do_provision_spotlight(action: Action, on_conflict: str) -> ActionResult:
"provision_github_actions": _do_provision_github_actions,
"provision_github_browser": _do_provision_github_browser,
"provision_tmux": _do_provision_tmux,
"provision_env": _do_provision_env,
"provision_global_excludes": _do_provision_global_excludes,
"provision_spotlight": _do_provision_spotlight,
"provision_tools": _do_provision_tools,
Expand Down
1 change: 1 addition & 0 deletions riglib/areas.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class Area:
Area("permissions", "harness permissions (allow / deny / ask)", GLOBAL, ("permissions",)),
Area("mode", "agent operating mode", GLOBAL, ("mode",)),
Area("tmux", "tmux config", GLOBAL, ("tmux",)),
Area("env", "shell environment variables", GLOBAL, ("env",)),
Area("models", "model-freshness cron", GLOBAL, ("models",)),
Area("tg_ctl", "tg-ctl inbound daemon", GLOBAL, ("tg_ctl",)),
Area("tools", "personal CLI ecosystem (tg/review/task/draw/…)", GLOBAL, ("tools",)),
Expand Down
Loading
Loading