chore(dotfiles): streamline workstation configuration workflows

This commit is contained in:
2026-08-25 00:00:55 +02:00
parent a81bbf5115
commit 4a20edba5c
12 changed files with 279 additions and 18 deletions
-8
View File
@@ -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 gnome-terminal = gnome-terminal -- "$@"
mime ^ranger/x-terminal-emulator, has xterm = xterm -e "$@" 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 # Generic file openers
#------------------------------------------- #-------------------------------------------
+4 -2
View File
@@ -1,6 +1,6 @@
{ {
"quietStartup": true, "quietStartup": true,
"lastChangelogVersion": "0.80.10", "lastChangelogVersion": "0.84.2",
"defaultProvider": "openai-codex", "defaultProvider": "openai-codex",
"defaultModel": "gpt-5.6-terra", "defaultModel": "gpt-5.6-terra",
"defaultThinkingLevel": "high", "defaultThinkingLevel": "high",
@@ -30,5 +30,7 @@
"prompts/english-default.md", "prompts/english-default.md",
"prompts/todo-hygiene.md" "prompts/todo-hygiene.md"
], ],
"theme": "dark" "theme": "dark",
"transport": "auto",
"tuiMode": "fullscreen"
} }
+90
View File
@@ -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/<scope>/<concern>-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.
+1 -2
View File
@@ -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: 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. - `.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. - `.scripts/` — optional shell, network, virtual-environment, and update helpers.
- `.setup/packages/` — package lists grouped by purpose. - `.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: 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/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. - `.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.
+26 -6
View File
@@ -61,9 +61,27 @@ does not explain a package. System and user units use the same naming:
## Commands ## 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 ```bash
cd ~/system-config
./scripts/verify.py
sudo decman --source source.py --dry-run --no-hooks sudo decman --source source.py --dry-run --no-hooks
sudo decman --source source.py --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: After intentional package changes, regenerate and review concern lists:
```bash ```bash
./scripts/split_packages.py ./system-config/decman refresh-package-state
./scripts/refresh_orphan_guards.py yadm diff -- system-config
./scripts/verify.py
git diff
``` ```
`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.
@@ -1,3 +1,5 @@
# Used by the Noctalia Pi Status Bridge adapter and the Hyprland launcher. # 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_BRIDGE_CLIENT=/home/alex/.local/bin/pi-status-bridge-client
PI_STATUS_UI_BINARY=/home/alex/.local/bin/pi-status-ui 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
@@ -5,6 +5,12 @@
-- stacking/tabbed, and resize-mode behavior is intentionally deferred. -- stacking/tabbed, and resize-mode behavior is intentionally deferred.
local machine = dofile(os.getenv("HOME") .. "/.config/hypr/machine.lua") 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 for _, monitor in ipairs(machine.monitors or {}) do
hl.monitor(monitor) hl.monitor(monitor)
end end
@@ -63,12 +69,14 @@ hl.workspace_rule({
-- Application and session bindings. -- Application and session bindings.
local noctalia_ipc = "noctalia msg " 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_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. -- 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 .. " + 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 .. " + RETURN", hl.dsp.exec_cmd(terminal))
hl.bind(main_mod .. " + CTRL + RETURN", hl.dsp.exec_cmd(terminal .. " -e zellij")) 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 .. " + 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 .. " + 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 .. " + 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 .. " + 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")) hl.bind(main_mod .. " + 0", hl.dsp.exec_cmd(noctalia_ipc .. "panel-toggle session"))
@@ -2,6 +2,13 @@
-- Noctalia replaces the i3-era Redshift, Polybar, Blueman Applet, and Picom stack. -- Noctalia replaces the i3-era Redshift, Polybar, Blueman Applet, and Picom stack.
return { return {
is_laptop = true, 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 = { input = {
touchpad = { touchpad = {
-- Standard laptop behavior: one/two/three-finger taps map to -- Standard laptop behavior: one/two/three-finger taps map to
+3
View File
@@ -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" "$@"
+15
View File
@@ -30,11 +30,13 @@ class UserConfigConcern(decman.Module):
name: str, name: str,
files: dict[str, str | tuple[str, int]] | None = None, files: dict[str, str | tuple[str, int]] | None = None,
directories: dict[str, str] | None = None, directories: dict[str, str] | None = None,
symlinks: dict[str, str] | None = None,
user: str = "alex", user: str = "alex",
) -> None: ) -> None:
super().__init__(f"user:{name}") super().__init__(f"user:{name}")
self._files = files or {} self._files = files or {}
self._directories = directories or {} self._directories = directories or {}
self._symlinks = symlinks or {}
self.user = user self.user = user
def files(self) -> dict[str, decman.File]: def files(self) -> dict[str, decman.File]:
@@ -60,6 +62,12 @@ class UserConfigConcern(decman.Module):
for target, source in self._directories.items() 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): class SystemConcern(decman.Module):
"""One host/system concern with explicit files and unit manifests.""" """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" "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/keyd/default.conf": "config/system/common/keyd.conf",
"/etc/NetworkManager/conf.d/wifi-powersave.conf": "config/system/laptop/wifi-powersave.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/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/X11/xorg.conf.d/30-touchpad.conf": "config/system/laptop/touchpad.conf",
"/etc/tlp.conf": "config/system/laptop/power/tlp.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", "/etc/systemd/sleep.conf.d/90-laptop.conf": "config/system/laptop/sleep.conf.d/90-laptop.conf",
@@ -16,6 +16,7 @@ go
gperf gperf
ibus ibus
imake imake
jsoncpp
karchive5 karchive5
kauth kauth
kauth5 kauth5
+122
View File
@@ -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())