From 4a20edba5cfb7bb2ac9eb426325bb7ec0c4af0f9 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Tue, 25 Aug 2026 00:00:55 +0200 Subject: [PATCH] chore(dotfiles): streamline workstation configuration workflows --- .config/ranger/rifle.conf | 8 -- .pi/agent/settings.json | 6 +- AGENTS.md | 90 +++++++++++++ README.md | 3 +- system-config/README.md | 32 ++++- .../environment.d/pi-status-bridge.conf | 2 + .../config/user/common/hypr/hyprland.lua | 8 ++ .../config/user/laptop/hypr/machine.lua | 7 + system-config/decman | 3 + system-config/modules/host_system.py | 15 +++ .../packages/ignored/repo-dependencies.txt | 1 + system-config/scripts/decman_cli.py | 122 ++++++++++++++++++ 12 files changed, 279 insertions(+), 18 deletions(-) create mode 100644 AGENTS.md create mode 100755 system-config/decman create mode 100755 system-config/scripts/decman_cli.py diff --git a/.config/ranger/rifle.conf b/.config/ranger/rifle.conf index d676430..10849b5 100644 --- a/.config/ranger/rifle.conf +++ b/.config/ranger/rifle.conf @@ -252,14 +252,6 @@ mime ^ranger/x-terminal-emulator, has konsole = konsole -e "$@" mime ^ranger/x-terminal-emulator, has gnome-terminal = gnome-terminal -- "$@" mime ^ranger/x-terminal-emulator, has xterm = xterm -e "$@" -#------------------------------------------- -# Misc -#------------------------------------------- -label wallpaper, number 11, mime ^image, has feh, X = feh --bg-scale "$1" -label wallpaper, number 12, mime ^image, has feh, X = feh --bg-tile "$1" -label wallpaper, number 13, mime ^image, has feh, X = feh --bg-center "$1" -label wallpaper, number 14, mime ^image, has feh, X = feh --bg-fill "$1" - #------------------------------------------- # Generic file openers #------------------------------------------- diff --git a/.pi/agent/settings.json b/.pi/agent/settings.json index 8620206..b82ea78 100644 --- a/.pi/agent/settings.json +++ b/.pi/agent/settings.json @@ -1,6 +1,6 @@ { "quietStartup": true, - "lastChangelogVersion": "0.80.10", + "lastChangelogVersion": "0.84.2", "defaultProvider": "openai-codex", "defaultModel": "gpt-5.6-terra", "defaultThinkingLevel": "high", @@ -30,5 +30,7 @@ "prompts/english-default.md", "prompts/todo-hygiene.md" ], - "theme": "dark" + "theme": "dark", + "transport": "auto", + "tuiMode": "fullscreen" } \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..09ca01b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,90 @@ +# Agent Guide: Alex's Dotfiles + +## Repository and safety model + +This directory (`/home/alex`) is the **yadm worktree** for personal, Arch-oriented dotfiles. Use `yadm` (rather than assuming a conventional clone) for status, diff, and tracked-file operations. + +- This is machine-specific configuration, **not** a bootstrap installer. There is no authoritative root install, link, overwrite, backup, or rollback command. +- Preserve `##class.*` filename variants; they are yadm alternatives for machine classes. +- Do not expose, copy, or add private hostnames, IP addresses, account names, tokens, or credentials. Keep machine-specific secrets in local/private configuration. +- Scope changes narrowly. Do not reformat unrelated dotfiles or overwrite existing user changes. +- Treat scripts that use `sudo`, modify `/etc`, install packages, rebuild boot artifacts, or alter services as potentially disruptive. Inspect them first; do not run them merely to validate an edit. + +Start with the root `README.md` for the dotfile-specific constraints and manual-validation guidance. + +## Ownership map + +| Path | Owns | +| --- | --- | +| Root dotfiles (`.zshrc`, `.config/`, `.scripts/`, etc.) | Ordinary yadm-managed user configuration and helpers | +| `.setup/` | Legacy/manual setup assets and package lists; it is not a complete installer | +| `system-config/` | Declarative Decman configuration for this Arch laptop | +| `system-config/config/system/` | Files deployed into system locations; do **not** edit deployed `/etc` targets | +| `system-config/config/user/` | Selected user dotfiles deployed by Decman; do **not** edit deployed copies | +| `system-config/packages/` | Native and AUR package lists organized by concern | +| `system-config/manifests/` | Enabled system and user unit manifests | +| `system-config/modules/` | Thin Decman deployment/loading adapters only | + +`system-config/` belongs to this yadm worktree; it is not a nested Git repository. + +## Decman architecture + +`system-config/source.py` is the Decman entrypoint. It currently selects the `laptop()` profile from `hosts.py` and applies modules in this order: + +1. `files` +2. `pacman` +3. `aur` +4. `systemd` + +`hosts.py` composes host profiles from package concerns: + +- Shared concerns: `common/core`, `wayland`, `fonts`, `development`, `productivity`, `media`, `research`, `network`, and `services`. +- Laptop-specific concern: `laptop/hardware`. +- Desktop support is intentionally sparse and future-facing. + +Keep policy in the declared data directories, not in Python glue: + +- Add a package to the narrowest explanatory `packages//-repo.txt` or `-aur.txt` file. A package must be owned exactly once. +- Put shared versus host-specific system policy in `config/system/common/` or the applicable host directory. +- Put selected user configuration in `config/user/{common,laptop,desktop}/`. +- Put service enablement in matching `manifests/{common,laptop,desktop}/` concern files. +- Keep `modules/` reusable and thin; it adapts package lists, file trees, and manifests to Decman. + +The ignored dependency lists are adoption guards that prevent Decman orphan cleanup from removing dependencies. Do not delete or regenerate them casually. + +## Change and validation workflow + +For any change, first identify the owning layer above. For Decman changes: + +1. Edit the source in `system-config/`, never the generated/deployed target. +2. Run the read-only consistency check from that directory: + + ```bash + ./scripts/verify.py + ``` + +3. After an intentional package-list change, regenerate and review the derived lists before applying: + + ```bash + ./scripts/split_packages.py + ./scripts/refresh_orphan_guards.py + ./scripts/verify.py + yadm diff -- system-config + ``` + +4. Require explicit human approval before applying system state. The review-first commands are: + + ```bash + sudo decman --source source.py --dry-run --no-hooks + sudo decman --source source.py --no-hooks + ``` + + The second command mutates system files, packages, and services; do not run it as routine validation. + +For ordinary dotfiles, use the smallest relevant validation: shell syntax/source checks for shell files, application/session testing for desktop configuration, or an explicit manual review. Preserve a rollback path when changing active configuration. + +## Before finishing + +- Review with `yadm diff --check` and `yadm diff`. +- Confirm that unrelated working-tree changes were not modified. +- State which validation was run and which system-changing operations were intentionally not run. diff --git a/README.md b/README.md index 19d268c..9d1aa2b 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Package lists in `.setup/packages/` are inputs for a user-managed installation; Start by reviewing the intended files and any machine-class variants. Typical areas are: - `.zshrc`, `.bashrc`, `.terminal_aliases`, `.tmux.conf`, and `.p10k.zsh` — shell and terminal setup. -- `.xmonad/`, `.Xresources##class.*`, `.xprofile##class.*`, and `.config/` — desktop/window-manager and application configuration. +- `.config/` — desktop and application configuration. - `.scripts/` — optional shell, network, virtual-environment, and update helpers. - `.setup/packages/` — package lists grouped by purpose. @@ -45,7 +45,6 @@ update_pacman_packages The following scripts mutate files outside this repository and must be inspected before use: -- `.setup/config/setup_touchpad.sh` copies an X11 configuration into `/etc/X11/xorg.conf.d/` when absent. - `.setup/config/exclude_from_update.sh` edits `/etc/pacman.conf` with `sudo` based on `~/.setup/packages/exclude_from_updating.list`. - `.setup/config/dinit_add_logs_to_all_services.sh` edits files in `/etc/dinit.d` and writes log specifications under `/var/log/dinit`; its `-o` option removes existing logfile lines before adding them. diff --git a/system-config/README.md b/system-config/README.md index bba5ca5..c68591b 100644 --- a/system-config/README.md +++ b/system-config/README.md @@ -61,9 +61,27 @@ does not explain a package. System and user units use the same naming: ## Commands +From the repository root, run common workflows through the +`./system-config/decman` wrapper, which uses fixed Decman arguments: + +```bash +./system-config/decman verify +./system-config/decman dry-run +./system-config/decman apply +``` + +`dry-run` and `apply` first run `verify`. They invoke Decman with `--no-hooks`, +matching the deliberately review-first direct commands below. `apply` changes +system state; use it only after reviewing the dry run. + +The wrapper locates its configuration relative to itself, so its commands run +from the `system-config` directory. Command output and errors stream directly +to the terminal. If a child command fails, the wrapper prints the failed command +and returns its exit status. A missing executable returns `127`, another +execution error (including a permission error) returns `126`, and an +interrupted workflow returns `130`. + ```bash -cd ~/system-config -./scripts/verify.py sudo decman --source source.py --dry-run --no-hooks sudo decman --source source.py --no-hooks ``` @@ -75,8 +93,10 @@ orphan cleanup from removing dependencies during adoption. After intentional package changes, regenerate and review concern lists: ```bash -./scripts/split_packages.py -./scripts/refresh_orphan_guards.py -./scripts/verify.py -git diff +./system-config/decman refresh-package-state +yadm diff -- system-config ``` + +`refresh-package-state` runs `split-packages`, `refresh-orphan-guards`, and +`verify` in that order. Those individual file-changing commands are also +available through the dispatcher when only one is needed. diff --git a/system-config/config/user/common/environment.d/pi-status-bridge.conf b/system-config/config/user/common/environment.d/pi-status-bridge.conf index 8c884c5..0d40a4d 100644 --- a/system-config/config/user/common/environment.d/pi-status-bridge.conf +++ b/system-config/config/user/common/environment.d/pi-status-bridge.conf @@ -1,3 +1,5 @@ # Used by the Noctalia Pi Status Bridge adapter and the Hyprland launcher. PI_STATUS_BRIDGE_CLIENT=/home/alex/.local/bin/pi-status-bridge-client PI_STATUS_UI_BINARY=/home/alex/.local/bin/pi-status-ui +# Ctrl+Super+Space starts a fresh Pi session in this worktree. Change as needed. +PI_STATUS_DEFAULT_WORKTREE=/home/alex/projects/pi-status-bridge diff --git a/system-config/config/user/common/hypr/hyprland.lua b/system-config/config/user/common/hypr/hyprland.lua index 84bdce5..f95afb2 100644 --- a/system-config/config/user/common/hypr/hyprland.lua +++ b/system-config/config/user/common/hypr/hyprland.lua @@ -5,6 +5,12 @@ -- stacking/tabbed, and resize-mode behavior is intentionally deferred. local machine = dofile(os.getenv("HOME") .. "/.config/hypr/machine.lua") +-- Machine-specific session environment must be set before Hyprland initializes +-- its graphics backend. The laptop profile selects only the Intel iGPU here. +for name, value in pairs(machine.environment or {}) do + hl.env(name, value) +end + for _, monitor in ipairs(machine.monitors or {}) do hl.monitor(monitor) end @@ -63,12 +69,14 @@ hl.workspace_rule({ -- Application and session bindings. local noctalia_ipc = "noctalia msg " local pi_status_ui_toggle = 'ui="${PI_STATUS_UI_BINARY:-$HOME/.local/bin/pi-status-ui}"; if test -x "$ui"; then setsid -f env TMPDIR=/tmp "$ui" --toggle >/tmp/pi-status-ui.log 2>&1; fi' +local pi_status_ui_new_session = 'ui="${PI_STATUS_UI_BINARY:-$HOME/.local/bin/pi-status-ui}"; if test -x "$ui"; then setsid -f env TMPDIR=/tmp "$ui" --new >/tmp/pi-status-ui.log 2>&1; fi' -- Tap and release Super on its own to open Noctalia's control-center home. hl.bind(main_mod .. " + SUPER_L", hl.dsp.exec_cmd(noctalia_ipc .. "panel-toggle control-center"), { release = true }) hl.bind(main_mod .. " + RETURN", hl.dsp.exec_cmd(terminal)) hl.bind(main_mod .. " + CTRL + RETURN", hl.dsp.exec_cmd(terminal .. " -e zellij")) hl.bind(main_mod .. " + SHIFT + RETURN", hl.dsp.exec_cmd("rofi -show combi")) hl.bind(main_mod .. " + SPACE", hl.dsp.exec_cmd(pi_status_ui_toggle)) +hl.bind(main_mod .. " + CTRL + SPACE", hl.dsp.exec_cmd(pi_status_ui_new_session)) hl.bind(main_mod .. " + N", hl.dsp.exec_cmd(noctalia_ipc .. "panel-toggle alex/knowledge-lookup:panel")) hl.bind(main_mod .. " + S", hl.dsp.exec_cmd(noctalia_ipc .. "panel-toggle control-center")) hl.bind(main_mod .. " + 0", hl.dsp.exec_cmd(noctalia_ipc .. "panel-toggle session")) diff --git a/system-config/config/user/laptop/hypr/machine.lua b/system-config/config/user/laptop/hypr/machine.lua index 2a34f11..37358f1 100644 --- a/system-config/config/user/laptop/hypr/machine.lua +++ b/system-config/config/user/laptop/hypr/machine.lua @@ -2,6 +2,13 @@ -- Noctalia replaces the i3-era Redshift, Polybar, Blueman Applet, and Picom stack. return { is_laptop = true, + -- Use the colon-free user-config alias for the stable PCI path rather than + -- cardN, whose numbering can change between boots. AQ_DRM_DEVICES itself + -- uses colons as device separators. This keeps Hyprland off the NVIDIA dGPU + -- so it can enter Runtime D3 when no application is explicitly offloaded. + environment = { + AQ_DRM_DEVICES = os.getenv("HOME") .. "/.config/hypr/intel-drm", + }, input = { touchpad = { -- Standard laptop behavior: one/two/three-finger taps map to diff --git a/system-config/decman b/system-config/decman new file mode 100755 index 0000000..f036b32 --- /dev/null +++ b/system-config/decman @@ -0,0 +1,3 @@ +#!/usr/bin/env sh +# Repository-local Decman interface; avoids changing the system `decman` command. +exec "$(dirname "$0")/scripts/decman_cli.py" "$@" diff --git a/system-config/modules/host_system.py b/system-config/modules/host_system.py index 8f62f4e..f1eca6c 100644 --- a/system-config/modules/host_system.py +++ b/system-config/modules/host_system.py @@ -30,11 +30,13 @@ class UserConfigConcern(decman.Module): name: str, files: dict[str, str | tuple[str, int]] | None = None, directories: dict[str, str] | None = None, + symlinks: dict[str, str] | None = None, user: str = "alex", ) -> None: super().__init__(f"user:{name}") self._files = files or {} self._directories = directories or {} + self._symlinks = symlinks or {} self.user = user def files(self) -> dict[str, decman.File]: @@ -60,6 +62,12 @@ class UserConfigConcern(decman.Module): for target, source in self._directories.items() } + def symlinks(self) -> dict[str, str | decman.Symlink]: + return { + link_name: decman.Symlink(target=target, owner=self.user) + for link_name, target in self._symlinks.items() + } + class SystemConcern(decman.Module): """One host/system concern with explicit files and unit manifests.""" @@ -177,6 +185,11 @@ def laptop_user_config() -> list[UserConfigConcern]: "config/user/laptop/noctalia/99-extend.toml" ), }, + symlinks={ + "/home/alex/.config/hypr/intel-drm": ( + "/dev/dri/by-path/pci-0000:00:02.0-card" + ), + }, ) ] @@ -242,6 +255,8 @@ def laptop_system() -> list[SystemConcern | UserConfigConcern]: "/etc/keyd/default.conf": "config/system/common/keyd.conf", "/etc/NetworkManager/conf.d/wifi-powersave.conf": "config/system/laptop/wifi-powersave.conf", "/etc/modprobe.d/iwlwifi.conf": "config/system/laptop/iwlwifi.conf", + "/etc/modprobe.d/nvidia-runtime-pm.conf": "config/system/laptop/modprobe.d/nvidia-runtime-pm.conf", + "/etc/udev/rules.d/80-nvidia-runtime-pm.rules": "config/system/laptop/udev/80-nvidia-runtime-pm.rules", "/etc/X11/xorg.conf.d/30-touchpad.conf": "config/system/laptop/touchpad.conf", "/etc/tlp.conf": "config/system/laptop/power/tlp.conf", "/etc/systemd/sleep.conf.d/90-laptop.conf": "config/system/laptop/sleep.conf.d/90-laptop.conf", diff --git a/system-config/packages/ignored/repo-dependencies.txt b/system-config/packages/ignored/repo-dependencies.txt index dc48a0a..3cda849 100644 --- a/system-config/packages/ignored/repo-dependencies.txt +++ b/system-config/packages/ignored/repo-dependencies.txt @@ -16,6 +16,7 @@ go gperf ibus imake +jsoncpp karchive5 kauth kauth5 diff --git a/system-config/scripts/decman_cli.py b/system-config/scripts/decman_cli.py new file mode 100755 index 0000000..ac125ce --- /dev/null +++ b/system-config/scripts/decman_cli.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Run the repository's common Decman workflows with safe, fixed commands.""" + +from __future__ import annotations + +import argparse +import shlex +import subprocess +import sys +from collections.abc import Callable, Sequence +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" + + +def format_command(command: Sequence[str] | str) -> str: + """Render a shell-safe command for diagnostic output.""" + return command if isinstance(command, str) else shlex.join(command) + + +def run(command: Sequence[str]) -> None: + """Print and run a command from the repository root.""" + print(f"+ {format_command(command)}", file=sys.stderr) + subprocess.run(command, check=True, cwd=ROOT) + + +def run_script(name: str) -> None: + run([sys.executable, str(SCRIPTS / name)]) + + +def verify() -> None: + """Check the active profile's package and unit invariants.""" + run_script("verify.py") + + +def dry_run() -> None: + """Verify the configuration, then preview Decman's changes.""" + verify() + run(["sudo", "decman", "--source", "source.py", "--dry-run", "--no-hooks"]) + + +def apply() -> None: + """Verify the configuration, then apply it without Decman hooks.""" + verify() + run(["sudo", "decman", "--source", "source.py", "--no-hooks"]) + + +def split_packages() -> None: + """Regenerate concern package lists from explicit installed packages.""" + run_script("split_packages.py") + + +def refresh_orphan_guards() -> None: + """Regenerate dependency-orphan guards from installed packages.""" + run_script("refresh_orphan_guards.py") + + +def refresh_package_state() -> None: + """Regenerate package data and verify the resulting configuration.""" + split_packages() + refresh_orphan_guards() + verify() + + +COMMANDS: dict[str, Callable[[], None]] = { + "verify": verify, + "dry-run": dry_run, + "apply": apply, + "split-packages": split_packages, + "refresh-orphan-guards": refresh_orphan_guards, + "refresh-package-state": refresh_package_state, +} + + +def failure_status(error: Exception) -> int | None: + """Report a launch failure and return its shell-compatible status.""" + if isinstance(error, subprocess.CalledProcessError): + status = error.returncode if error.returncode > 0 else 128 - error.returncode + print( + f"error: command exited with status {status}: {format_command(error.cmd)}", + file=sys.stderr, + ) + return status + if isinstance(error, FileNotFoundError): + print(f"error: executable not found: {error.filename}", file=sys.stderr) + return 127 + if isinstance(error, PermissionError): + print(f"error: executable is not permitted: {error.filename}", file=sys.stderr) + return 126 + if isinstance(error, OSError): + print( + f"error: unable to execute {error.filename}: {error.strerror}", + file=sys.stderr, + ) + return 126 + return None + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "command", + choices=COMMANDS, + help="workflow to run; apply and package-state commands modify system or files", + ) + args = parser.parse_args() + try: + COMMANDS[args.command]() + except KeyboardInterrupt: + print("error: interrupted", file=sys.stderr) + return 130 + except Exception as error: + status = failure_status(error) + if status is None: + raise + return status + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())