24 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, sourceslib/*.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 withpatch_file <name>(lib/common.sh, from the checkout); the bundle embeds every file inpatches/and overridespatch_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 withservice_file <name> [KEY=value...](lib/common.sh), which fills in@KEY@placeholders; the bundle embeds every file inservices/too (service_raw). Add new ones to its README. Small drop-ins for other packages' units stay inline.ui/- the app:steamify-ui(PySide6; runssteamify.sh --backend,lib/backend.sh) andui/qml/:Main.qml(window, header, which screen shows),AppState.qml(all state and logic, input actions), one*Screen.qmlper screen, small widgets (Btn,Chip,Badge, ...), and the singletonsTheme(colours, fonts),Texts(item texts) andInput(controller/keyboard/remote and its button names), listed inqmldir. Screens get theAppStateasappand 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 namecachyos-gamescope-boot;migrate_layoutmoves those of older versions (symlinks keep older releases working). Keep the.bak-gamescope-wizardbackup 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 inTECHNICAL.md.- Components. Every menu item
<id>(listed inCOMPONENTSandLABELinlib/menu.sh) provides<id>_status(return 0 if on, detected from the system, no state file needed),<id>_enableand<id>_disable, all idempotent.<id>_enableis also used to re-apply. Optional<id>_availableis checked viacomponent_available(e.g.machineonly on Fremont). Turn-on order isCOMPONENTSorder, turn-off reverse;gamingmust stay first. Dependencies live intoggle_component. - Feature versions. Set
FEATURE_VERSION[<id>](lib/menu.sh) to the newVERSIONwhenever what<id>_enablesets 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 asoff(feature_record_unticked), orfeature_newwould tick them again. Status still comes from the system; don't add ad-hoc<id>_repairchecks 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>_disablecallskrevert <component>. Never callkwriteconfig6directly for settings a component owns. System files are backed up withbackup_fileand restored on disable. - Single-file build.
.github/tools/bundle.shinlineslib/*.shin the order of the entry point'sfor lib in ...; dosource loop and wraps everything after that loop inmain(). Keep that loop on one line, keep all logic in functions, and don't rely onSCRIPT_DIRfor anything but sourcing. The workflow is one file for GitHub and a Gitea mirror (github.server_url): build and check run on both (shellcheck throughludeeus/action-shellcheck, which brings its own binary; the artifact withupload-artifact@v4on GitHub andchristopherhx/gitea-upload-artifact@v4elsewhere: never mentionupload-artifact@v3, GitHub fails a workflow for a deprecated version even in a skipped step); the release isghon GitHub andakkuman/gitea-release-actionon Gitea. A new release on GitHub also starts the Steamify ISO's release (gh workflow run iso-release.ymlin steammachine-cachyos-live-iso, secretISO_DISPATCH_TOKEN; skipped without it): the ISO repo's GitHub tag names it, the Gitea mirror builds it. CI (.github/workflows/bundle.yml) publishes the bundle as releasev$VERSIONon pushes tomain; an existing version is never overwritten, so bumpVERSIONfor 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
sudoper command, never require root. Per-user files go in$HOMEof 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_filebefore the first edit. -
Don't overwrite package-owned files in
/etc/xdgor/usr. KDE settings go into the user's config viakwriteconfig6(ormerge_kde_configfor 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.mdunder 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.
- subject; a commit can't contain its own hash, so fill it in with the
next commit). The section heading must be
- Branches: one release branch per version. Work for a release goes
into
release/X.Y.Z(e.g.release/2.7.0), branched frommainwhen that version starts. That branch bumpsVERSIONtoX.Y.Zand 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) orbugfix/<name>(a fix), and a pull request into the release branch, not intomain. 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 intomain. Merging it publishes the release (below). - A fix for a released version: a new
release/X.Y.Z+1frommain, with thebugfix/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 (withgh:gh pr create --base release/X.Y.Z+gh pr merge --merge; no review needed). Withoutgh(command -v gh), ask the user to install it (Ubuntu:sudo apt install gh, thengh auth login) instead of working around it. Only the release branch's pull request intomainis the user's: never commit or merge tomain, 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 mergingmainwhen that moved.
- Every change gets its own branch from the release branch:
- A push to
mainpublishes releasev$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 whenVERSIONis 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 newVERSION, scaled to 1600 px wide. - Users install through
releases/latest/download/steamify.sh(GitHub's newest release). Thelatesttag and release follow the newest version tag too (moved, asset replaced, when a new version is released), so the older URLreleases/download/latest/...keeps working.
- Every commit gets an entry in
Non-obvious behaviour to preserve
-
apply_changessets$LOGIN_MANAGERfrom the menu:sddmwhen single user mode is wanted, elseplasmalogin. Toggling single user re-appliesgamingso it moves to the other login manager. Single user mode hides the launcher's Session dropdown via kickoffprimaryActions=3(restrictingaction/logoutalso hides Restart/Shut Down). Two login-manager paths: sddm (like SteamOS:steam-set-sessionwrites/etc/sddm.conf.d/zz-steamos-autologin.conf, which SDDM honours; we addUser=/Relogin=in10-gamescope-autologin.conf, and/etc/sddm.confmust 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-sessiononly writes/etc/plasmalogin.conf.d/zz-steamos-autologin.conf; the base/etc/plasmalogin.confwins, hence the sync bridge. The sync service needsStartLimitIntervalSec=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-vaporpackage (system-wide, in/usr/share); only remove it on disable if we installed it. It is applied withlookandfeeltool --resetLayout("Desktop and window layout"), which replaces the panel/desktop layout: the layout files are backed up to$STATE_DIR/theme-layoutand restored, and every key from Vapor'scontents/defaultsgoes throughksetfirst.plasma-apply-colorschemeruns with theColorSchemekey cleared, since it skips a scheme already named there. The layout reset drops single user's launcher settings, sosingle_launcheris re-applied. -
SteamOS extras (
lib/steamos-extras.sh, part of the theme): downloaded from Valve's neweststeamdeck-kde-presets(repo db gives name + SHA-256) into/usr/local, never/usr.gaming-return.svgis a symlink in the package: installsteam-gaming-return.svgunder that name. The shortcut's icon is switched withset_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), notplasmashell --replacefrom 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 whenplasmashellis 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_editandrestart_plasmashell_if_stopped(lib/common.sh). -
LED driver:
leds-valve-dkms-git's Makefile builds againstuname -r, not DKMS's target kernel:/etc/dkms/leds-valve-dkms.confsetsMAKE[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 addsLLVM=1only for the target's tree). Headers for every installed kernel are installed first, andensure-kernel-headers.serviceinstalls 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_visibleonly shows it (indented,└) whilegamingis ticked, so menu numbers after it shift by one when the conversion is unticked. It needsgaming(ticking it ticks gaming, unticking gaming unticks it) and is never preselected (NO_PRESELECT), so the conversion boots into gamescope by default. Desktop issteamify-boot-desktop.service, orderedBefore=the login managers:steam-set-session plasma.desktopplus the plasmalogin sync bridge when it exists, at every boot. CachyOS'scachyos-gamescope-autologinstill sets gamescope during each desktop session; the unit corrects it at boot. -
biosis an action (ACTIONSinlib/menu.sh) and a sub-option ofmachine, not an on/off component: never preselected (not even on a first run), never re-applied bya, not listed in the state overview, andbios_statusis 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: noUpdateError) 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 typingUPDATE) and the warnings; the firmware comes from the newestholo-X.Yrepo (.filesdb names the.cab,.dbgives the SHA-256), and fwupd itself refuses non-Fremont hardware. In the VM, test it withWIZARD_BIOS_DRY_RUN=1(skips only the device check, never flashes) and a faked version (dev-envBIOS_VERSION=F7F0107 ./run.sh --fremont). -
The entry point loops: menu, run, "back to the menu" (or
[m]/[r]when a restart is needed), untilq. When a restart is needed,qasks "Restart now? [Y/n]" (quit_prompt):ngoes back to the menu, the nextqasks again. Scripted input that runs out quits without restarting (never restart on EOF:ask_ynwould take its default). -
Steamify shortcut (
launcher): "Steamify CachyOS" (desktop and launcher, idsteamify-ui.desktop, the app's own id) runscurl | bashofreleases/latest/download/steamify-app.shwithout a terminal (in Konsole only while PySide6 is missing, for sudo); "Steamify Terminal" (launcher) runssteamify.shin Konsole. Both always the newest release. The entry hasX-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 bylauncher_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 usespipefail, 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_enablerefuses. debugfs is root-only (glob it under sudo), andedid_overridetakes exactlyresetwith no newline. -
Steam Machine CEC driver (
cec_driver_enable,lib/cec.sh): mainlinecros_ec_ceclacks Fremont, so HDMI-CEC builds Valve's copy (evlaVlinux-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/cec0exists but stays atf.f.f.f. The source comes from one raw.githubusercontent.com download, which GitHub rate-limits (HTTP 429 after a day of test installs, per address): it's kept in/var/cache/steamify/cros-ec-cec-<sha256:12>.cand reused while its checksum matches. The name carries the checksum, so a new pin (commit + SHA-256 inlib/cec.sh) downloads its own file once and removes the old one. The repo is an unofficial mirror of Valve's kernel without releases: if it ever disappears, ship the file with Steamify. The test VMs share the host's cache. -
Power-off fix (
poweroff,lib/fremont-poweroff.sh, a default sub-option ofmachine): 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 modulesteamify-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:kpinis only available while on (kpin_available), anddetect_componentsunticks it, so a normal run removes an existing pin.hdmi(it needed the pin) is retired the same way:hdmi_statusis on while anything of it is left (saved displays, hotplug unit, EDID files, old kernel parameter),hdmi_availableonly then, it's always unticked, andhdmi_disableremoves all of it. CachyOS'slinux-cachyosis clang-built,-boreGCC-built: let DKMS pick the compiler, never passLLVM=1. Test shutdown on the real machine for every new major kernel. -
Add as non-Steam game (
steamgame,lib/steam-game.sh, sub-option oflauncher, ticked along with it, 2.5.1): edits Steam'sshortcuts.vdfwithpatches/steam-shortcuts.pyonly while Steam is closed (Steam rewrites it on exit), then starts Steam again. Never close Steam in gaming mode or whenSteamGameIdis 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 byplasma-workspace.target) runspatches/steamify-notifier.py, which compares GitHub's newest release with theseenversion every run records (notify_seenin the entry point, only while it's on, withfrontend: app or terminal, which Open Steamify starts again, in asystemd-run --scopesince 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 withoutplasmashell). -
VRAM booster (
vram,lib/vram-booster.sh, top-level, ticked by default, 2.3.0; only available when/sys/fs/cgroup/dmem.capacitylists avram/vidmemregion (numbered too) of at least 2 GB, whatever the brand; greyed out with an NVIDIA card whose driver lists none,vram_selectable, faked withWIZARD_VRAM_FAKE_NVIDIA=1;WIZARD_VRAM_CAPACITY=<file>reads the regions from a copy): CachyOS'sdmemcg-booster(system + user service) andplasma-foreground-booster, like SteamOS 3.9's VRAM management. The latter only starts withkcgroupsrc [Foreground Booster] autostart=true(set withkset). 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 (steamify-cachyos-live-iso) runs it in the installer for a user who has never logged in: no session bus, user systemd or plasmashell. So everysystemctl --usergoes throughuser_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 withgaming) (2.7.0,defaults_optionsinlib/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|desktopon its own (2.7.0, for scripts) only switchesbooton an installed conversion:WANTEDisCURRENTplus 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's OS Name is
lsb_release -d, not touched)./etc/os-releasegetsVERSION_ID=steamos-X.Y(Steam's OS Version: Valve's newest jupiter repo; offline, the previous value),VERSION_CODENAME=steam-machine,VARIANT="Steamify <version>"andVARIANT_ID=steamify-<version>(Steam shows VARIANT_ID as OS Variant), refreshed byos_version_refreshafter every run that changes something; a pacman hook (zz-steamify-os-release) runs after cachyos-hooks. KDE's About this System would show VERSION_ID after the name:kcm-about-distrorcUseOSReleaseVersion=true(kset) makes it show os-release's VERSION, which CachyOS doesn't have. NeverNAMEorPRETTY_NAME: limine-snapper-sync finds the boot entries by the OS name ("Target OS name ... not found in /boot/limine.conf");/etc/default/liminegetsTARGET_OS_NAME="CachyOS"when it has none, as a guard. The serial number (tmpfiles rule) is Steam Machine support's, so Fremont only. -
Relogin=truemeans 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.