Files
steamify-cachyos/AGENTS.md
T

22 KiB

AGENTS.md

Guidance for AI coding agents working on this repository. See README.md for what the script does from a user's point of view.

Project overview

Steamify CachyOS: a bash wizard that makes CachyOS (KDE Plasma 6 + plasma-login-manager) boot into a SteamOS-style gamescope session, with working switching between gamescope and the Plasma desktop. Primary target: the Valve Steam Machine (DMI Valve/Fremont) running CachyOS Desktop edition.

Layout

  • steamify.sh - the only entry point. Pre-flight checks, sources lib/*.sh, runs the menu and applies the plan. No component logic here.
  • patches/ - kernel module sources and patches the scripts build or apply, never inline in the shell code. Read them with patch_file <name> (lib/common.sh, from the checkout); the bundle embeds every file in patches/ and overrides patch_file. Add new ones to its README.
  • services/ - the systemd units the scripts install (.service, .timer, .path), never inline in the shell code. Read them with service_file <name> [KEY=value...] (lib/common.sh), which fills in @KEY@ placeholders; the bundle embeds every file in services/ too (service_raw). Add new ones to its README. Small drop-ins for other packages' units stay inline.
  • ui/ - the app: steamify-ui (PySide6; runs steamify.sh --backend, lib/backend.sh) and ui/qml/: Main.qml (window, header, which screen shows), AppState.qml (all state and logic, input actions), one *Screen.qml per screen, small widgets (Btn, Chip, Badge, ...), and the singletons Theme (colours, fonts), Texts (item texts) and Input (controller/keyboard/remote and its button names), listed in qmldir. Screens get the AppState as app and only show it or call its functions.
  • Paths. Everything in the home folder lives under steamify: $STATE_DIR (~/.local/state/steamify), $STEAMIFY_DATA/$STEAMIFY_BIN (~/.local/share/steamify/{app,bin}), lib/state.sh. Never add files under the old name cachyos-gamescope-boot; migrate_layout moves those of older versions (symlinks keep older releases working). Keep the .bak-gamescope-wizard backup suffix: existing backups are found by it.
  • lib/*.sh - one file per responsibility, each defining functions only (no top-level side effects besides constants). See the table in TECHNICAL.md.
  • Components. Every menu item <id> (listed in COMPONENTS and LABEL in lib/menu.sh) provides <id>_status (return 0 if on, detected from the system, no state file needed), <id>_enable and <id>_disable, all idempotent. <id>_enable is also used to re-apply. Optional <id>_available is checked via component_available (e.g. machine only on Fremont). Turn-on order is COMPONENTS order, turn-off reverse; gaming must stay first. Dependencies live in toggle_component.
  • Feature versions. Set FEATURE_VERSION[<id>] (lib/menu.sh) to the new VERSION whenever what <id>_enable sets up changes (a new component gets one too): installs recorded with an older version are ticked and re-applied ("update" in the plan and the app). New default options are ticked for installs whose parent (top-level: gaming) is on (feature_new, shown as "new"). Options shown but left unticked in a confirmed run are recorded as off (feature_record_unticked), or feature_new would tick them again. Status still comes from the system; don't add ad-hoc <id>_repair checks for new changes.
  • Reversibility. Every per-user KDE setting a component changes goes through kset <component> <file> <group|group> <key> <value> (lib/state.sh), which records the old value once; <id>_disable calls krevert <component>. Never call kwriteconfig6 directly for settings a component owns. System files are backed up with backup_file and restored on disable.
  • Single-file build. .github/tools/bundle.sh inlines lib/*.sh in the order of the entry point's for lib in ...; do source loop and wraps everything after that loop in main(). Keep that loop on one line, keep all logic in functions, and don't rely on SCRIPT_DIR for anything but sourcing. CI (.github/workflows/bundle.yml) publishes the bundle as release v$VERSION on pushes to main; an existing version is never overwritten, so bump VERSION for every release.

Conventions

  • Bash, set -uo pipefail (no -e): check exit codes of steps that matter explicitly (|| { err ...; exit 1; } or || return 1).

  • Use the helpers from lib/common.sh (info, ok, warn, err, ask_yn, backup_file) for all output and prompts.

  • Runs as the normal user; use sudo per command, never require root. Per-user files go in $HOME of the invoking user (the script refuses to configure another user).

  • Idempotent: every step must be safe to re-run without duplicating lines or settings. Back up system files with backup_file before the first edit.

  • Don't overwrite package-owned files in /etc/xdg or /usr. KDE settings go into the user's config via kwriteconfig6 (or merge_kde_config for whole Valve ini files).

  • Package installs after a user already confirmed use --noconfirm: a second pacman prompt consumes the next scripted answer.

  • Comments explain why (the CachyOS/Plasma quirk being worked around), not what the next line does.

  • Versioning and releases. SemVer in VERSION (steamify.sh, shown in the menu header and the bundle header).

    • Every commit gets an entry in CHANGELOG.md under its version (short hash
      • subject; a commit can't contain its own hash, so fill it in with the next commit). The section heading must be ## <VERSION> - <date>: CI cuts the release notes out of the changelog by that heading.
    • Branches: one release branch per version. Work for a release goes into release/X.Y.Z (e.g. release/2.7.0), branched from main when that version starts. That branch bumps VERSION to X.Y.Z and adds the ## X.Y.Z - <date> changelog section (first commit on it).
      • Every change gets its own branch from the release branch: feature/<name> (new behaviour) or bugfix/<name> (a fix), and a pull request into the release branch, not into main. Its commits add their changelog lines to that version's section.
      • When the release is done and tested (the test plan in steamify-cachyos-dev, TESTPLAN.md), the release branch gets a pull request into main. Merging it publishes the release (below).
      • A fix for a released version: a new release/X.Y.Z+1 from main, with the bugfix/ branch into it; don't reopen an old release branch.
      • Up to the release branch, the agent manages it: create the feature//bugfix/ branches, commit, push, and merge them into the release branch itself once tested (with gh: gh pr create --base release/X.Y.Z + gh pr merge --merge; no review needed). Without gh (command -v gh), ask the user to install it (Ubuntu: sudo apt install gh, then gh auth login) instead of working around it. Only the release branch's pull request into main is the user's: never commit or merge to main, the user merges that one. Keep a feature/bugfix branch up to date by merging (or rebasing on) its release branch, the release branch by merging main when that moved.
    • A push to main publishes release v$VERSION (tag + bundle + that changelog section) and marks it latest. An existing version is never overwritten: without a bump nothing is released, and pull requests show a warning when VERSION is a version that's already released (so a release branch that forgot its bump shows it on every PR).
    • A release that adds, removes or renames a menu row also retakes the README screenshot (assets/screenshot-menu.png): the app from the branch on a Steam Machine, every row visible (window 1280 wide, tall enough), the header showing the new VERSION, scaled to 1600 px wide.
    • Users install through releases/latest/download/steamify.sh (GitHub's newest release). The latest tag and release follow the newest version tag too (moved, asset replaced, when a new version is released), so the older URL releases/download/latest/... keeps working.

Non-obvious behaviour to preserve

  • apply_changes sets $LOGIN_MANAGER from the menu: sddm when single user mode is wanted, else plasmalogin. Toggling single user re-applies gaming so it moves to the other login manager. Single user mode hides the launcher's Session dropdown via kickoff primaryActions=3 (restricting action/logout also hides Restart/Shut Down). Two login-manager paths: sddm (like SteamOS: steam-set-session writes /etc/sddm.conf.d/zz-steamos-autologin.conf, which SDDM honours; we add User=/Relogin= in 10-gamescope-autologin.conf, and /etc/sddm.conf must not contain [Autologin] since it's read last) and plasmalogin (needs the sync bridge and the shortcut's sudoers rule). Both must keep working.

  • steam-set-session only writes /etc/plasmalogin.conf.d/zz-steamos-autologin.conf; the base /etc/plasmalogin.conf wins, hence the sync bridge. The sync service needs StartLimitIntervalSec=0, or bursts of session switches get it rate-limited and all later switches silently stop working.

  • The sync script must only edit Session= inside [Autologin].

  • The Steam desktop autostart unit is guarded with ExecCondition=... XDG_CURRENT_DESKTOP = KDE, so it doesn't start a second Steam inside gamescope.

  • Vapor theme: comes from the cachyos-vapor package (system-wide, in /usr/share); only remove it on disable if we installed it. It is applied with lookandfeeltool --resetLayout ("Desktop and window layout"), which replaces the panel/desktop layout: the layout files are backed up to $STATE_DIR/theme-layout and restored, and every key from Vapor's contents/defaults goes through kset first. plasma-apply-colorscheme runs with the ColorScheme key cleared, since it skips a scheme already named there. The layout reset drops single user's launcher settings, so single_launcher is re-applied.

  • SteamOS extras (lib/steamos-extras.sh, part of the theme): downloaded from Valve's newest steamdeck-kde-presets (repo db gives name + SHA-256) into /usr/local, never /usr. gaming-return.svg is a symlink in the package: install steam-gaming-return.svg under that name. The shortcut's icon is switched with set_shortcut_icon, since the conversion is created before the theme. An existing KWallet is never replaced or removed.

  • Plasma 6 has no separate systemtray containment: tray settings live on the systemtray applet itself (Valve's setup script targets Plasma 5).

  • Restart plasmashell via systemctl --user (plasma-plasmashell.service), not plasmashell --replace from the script's shell: a shell without the session environment yields a light-themed desktop (context menus, apps it launches).

  • Live Plasma changes (qdbus6 ... evaluateScript, lookandfeeltool) only run when plasmashell is running; config-file fallbacks cover the rest.

  • plasmashell writes its in-memory config back on exit: edit panel/applet/ wallpaper files only between stop_plasmashell_for_edit and restart_plasmashell_if_stopped (lib/common.sh).

  • LED driver: leds-valve-dkms-git's Makefile builds against uname -r, not DKMS's target kernel: /etc/dkms/leds-valve-dkms.conf sets MAKE[0]="make KVERSION=${kernelver}" (written before the AUR install; someone's own override there is backed up, kept with ours appended, and restored on disable). Without it, other kernels build against the running kernel's tree and fail (CachyOS kernels are clang-built; DKMS adds LLVM=1 only for the target's tree). Headers for every installed kernel are installed first, and ensure-kernel-headers.service installs missing ones at boot (a pacman hook can't run pacman), which triggers DKMS's install hook. The module creates /sys/class/leds/valve-leds*.

  • boot ("Boot into: [gamescope] / desktop") is a choice row, not a checkbox: on = desktop. It's a sub-option of the conversion: menu_visible only shows it (indented, └) while gaming is ticked, so menu numbers after it shift by one when the conversion is unticked. It needs gaming (ticking it ticks gaming, unticking gaming unticks it) and is never preselected (NO_PRESELECT), so the conversion boots into gamescope by default. Desktop is steamify-boot-desktop.service, ordered Before= the login managers: steam-set-session plasma.desktop plus the plasmalogin sync bridge when it exists, at every boot. CachyOS's cachyos-gamescope-autologin still sets gamescope during each desktop session; the unit corrects it at boot.

  • bios is an action (ACTIONS in lib/menu.sh) and a sub-option of machine, not an on/off component: never preselected (not even on a first run), never re-applied by a, not listed in the state overview, and bios_status is always off. Only selectable when Valve's version differs from the installed one (component_selectable, greyed out otherwise). Download, SHA-256 and the fwupd device check (get-details --json: no UpdateError) come before the warnings. The warning box uses █ for its frame: Konsole draws a long coloured row of # narrower, so the right edge wouldn't line up. Keep both confirmations (y/N, then typing UPDATE) and the warnings; the firmware comes from the newest holo-X.Y repo (.files db names the .cab, .db gives the SHA-256), and fwupd itself refuses non-Fremont hardware. In the VM, test it with WIZARD_BIOS_DRY_RUN=1 (skips only the device check, never flashes) and a faked version (dev-env BIOS_VERSION=F7F0107 ./run.sh --fremont).

  • The entry point loops: menu, run, "back to the menu" (or [m]/[r] when a restart is needed), until q. When a restart is needed, q asks "Restart now? [Y/n]" (quit_prompt): n goes back to the menu, the next q asks again. Scripted input that runs out quits without restarting (never restart on EOF: ask_yn would take its default).

  • Steamify shortcut (launcher): "Steamify CachyOS" (desktop and launcher, id steamify-ui.desktop, the app's own id) runs curl | bash of releases/latest/download/steamify-app.sh without a terminal (in Konsole only while PySide6 is missing, for sudo); "Steamify Terminal" (launcher) runs steamify.sh in Konsole. Both always the newest release. The entry has X-Steamify-Shortcut=true: steamify-app.sh doesn't overwrite it, and disable only removes it then. A pre-2.0.1 shortcut (terminal only) is ticked by launcher_repair. Its icon, assets/steam-gaming-settings.svg (Valve's GPL-2.0 return icon with a gear), is a release asset too, and is downloaded from there (Steam's icon if that fails). Desktop files are written with their mode already set (install_executable): Plasma opens a desktop icon it first saw non-executable in an editor. The start script closes its window after a 10-second countdown on success and waits for Enter after an error; it uses pipefail, or a failed download would run an empty script and count as success.

  • HDMI refresh boost (hdmi, lib/hdmi-refresh.sh): retired in 2.2.0 (it needed the pinned kernel); only removal is left (see the power-off fix below). hdmi_enable refuses. debugfs is root-only (glob it under sudo), and edid_override takes exactly reset with no newline.

  • Steam Machine CEC driver (cec_driver_enable, lib/cec.sh): mainline cros_ec_cec lacks Fremont, so HDMI-CEC builds Valve's copy (evlaV linux-integration, pinned commit + SHA-256) with DKMS for every kernel. It's patched to register its notifier without a port name when the board has one CEC port: amdgpu registers its HDMI notifier nameless, and the kernel only pairs that with a named lookup ("Port C") when the CEC driver registered first, which never happens since amdgpu loads from the initramfs. Without it /dev/cec0 exists but stays at f.f.f.f.

  • Power-off fix (poweroff, lib/fremont-poweroff.sh, a default sub-option of machine): recent kernels (7.2 and the 6.x/7.0/7.1 updates with the backport) keep the firmware's S4/S5 wake bit on GPIO pin 18, so the Steam Machine boots again right after powering off (Valve's kernel clears it at probe, not for upstream). The DKMS module steamify-fremont-poweroff (patches/steamify-fremont-poweroff.c) clears it in a power-off-prepare handler, built for each kernel. It made the kernel pin unnecessary: kpin is only available while on (kpin_available), and detect_components unticks it, so a normal run removes an existing pin. hdmi (it needed the pin) is retired the same way: hdmi_status is on while anything of it is left (saved displays, hotplug unit, EDID files, old kernel parameter), hdmi_available only then, it's always unticked, and hdmi_disable removes all of it. CachyOS's linux-cachyos is clang-built, -bore GCC-built: let DKMS pick the compiler, never pass LLVM=1. Test shutdown on the real machine for every new major kernel.

  • Add as non-Steam game (steamgame, lib/steam-game.sh, sub-option of launcher, ticked along with it, 2.5.1): edits Steam's shortcuts.vdf with patches/steam-shortcuts.py only while Steam is closed (Steam rewrites it on exit), then starts Steam again. Never close Steam in gaming mode or when SteamGameId is set (the run is a Steam game). An existing entry for the start script is updated, not doubled; removal takes every entry for it. Controller input through Steam's desktop layout arrives as keys: Enter must select (A), Esc must never quit (B); the app re-execs without Steam's overlay preload.

  • Update notifications (notify, lib/update-notifier.sh, top-level, ticked by default, 2.5.0): a user timer (daily, and the service is wanted by plasma-workspace.target) runs patches/steamify-notifier.py, which compares GitHub's newest release with the seen version every run records (notify_seen in the entry point, only while it's on, with frontend: app or terminal, which Open Steamify starts again, in a systemd-run --scope since the check's own unit is stopped when it exits). It must never update anything itself: notification + tray icon, Open Steamify / Skip this version (skipped). Desktop only (exits without plasmashell).

  • VRAM booster (vram, lib/vram-booster.sh, top-level, ticked by default, 2.3.0; only available when /sys/fs/cgroup/dmem.capacity lists a vram/vidmem region (numbered too) of at least 2 GB, whatever the brand; greyed out with an NVIDIA card whose driver lists none, vram_selectable, faked with WIZARD_VRAM_FAKE_NVIDIA=1; WIZARD_VRAM_CAPACITY=<file> reads the regions from a copy): CachyOS's dmemcg-booster (system + user service) and plasma-foreground-booster, like SteamOS 3.9's VRAM management. The latter only starts with kcgroupsrc [Foreground Booster] autostart=true (set with kset). Disable removes only the packages it installed (state_get vram installed_pkgs).

  • Install-time mode (steamify.sh --defaults, 2.6.0): applies what the menu would preselect, no menu or prompts (needs passwordless sudo). The Steam Machine ISO (steammachine-cachyos-live-iso) runs it in the installer for a user who has never logged in: no session bus, user systemd or plasmashell. So every systemctl --user goes through user_systemctl (lib/common.sh; without a session only unit files change, via --root=/), and the theme, when the layout is still /etc/skel's, removes it so Plasma builds Vapor's layout at the first login. What needs that layout (single user's launcher) runs then, from a one-time autostart (lib/first-login.sh, --first-login) that also opens the app. --options <id>,... (exactly these on, the rest off; an item on brings its parent) and --boot gamescope|desktop (desktop only with gaming) (2.7.0, defaults_options in lib/menu.sh); the ISO's installer page passes them. Items this PC can't use are left off with a warning, not an error. steamify.sh --boot gamescope|desktop on its own (2.7.0, for scripts) only switches boot on an installed conversion: WANTED is CURRENT plus that, so no updates or removals a normal run would pick.

  • What Steam's System settings show (SteamOS conversion, 2.9.0): the OS name stays CachyOS's everywhere (legal clarity; Steam reads lsb_release -d, not touched). /etc/os-release gets only VARIANT="Steamify <version>", VARIANT_ID and VERSION_CODENAME=steamos-X.Y (no VERSION_ID: KDE's About this System shows it after the name, "CachyOS Linux 2.9.0") (Valve's newest jupiter repo), refreshed by os_version_refresh after every run that changes something; a pacman hook (zz-steamify-os-release) runs after cachyos-hooks. Never NAME or PRETTY_NAME: limine-snapper-sync finds the boot entries by the OS name ("Target OS name ... not found in /boot/limine.conf"); /etc/default/limine gets TARGET_OS_NAME="CachyOS" when it has none, as a guard.

  • Relogin=true means a gamescope that fails to start is relaunched in a tight loop; keep that in mind when changing session handling.

Checking changes

The bundle puts every module in one file, so shellcheck sees all their local variables together: don't reuse a name another module uses as an array (e.g. g in lib/state.sh), or CI's shellcheck on the bundle fails.

There is no test suite. At minimum:

for f in steamify.sh lib/*.sh; do bash -n "$f"; done
shellcheck -S warning steamify.sh lib/*.sh   # if available

Behaviour is verified in a CachyOS QEMU/KVM test VM. The VM scripts and a Claude Code skill describing the whole test workflow (snapshots, SSH, running the wizard with scripted menu input such as printf '2\n\ny\nn\n', the per-component checks; when stdin is not a terminal the menu falls back to a numbered prompt, which is what scripted runs use, reboot checks, the full test matrix, --fremont to fake Steam Machine hardware) live in steamify-cachyos-dev. Gamescope itself and the real LED bar can only be verified on hardware.