26 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. - NVIDIA (
lib/nvidia.sh): gamescope's session is broken on NVIDIA, so the SteamOS conversion (gaming,boot,single,glyphs) is hidden there (unless already on) andnvidia+ its sub-optionbigpicture("Gaming on NVIDIA") replace it: Steam on the Plasma desktop, started at login. Never reintroduce a gamescope session for NVIDIA without re-testing on the hardware (notes: steamify-cachyos-dev,nvidia/). It can't be tested without the hardware: keep the branch behind.no-release-yetuntil it was. - 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"). New opt-in options (NO_PRESELECT) get the same "new" badge without being ticked (feature_new_optin). Both count "Gaming on NVIDIA" as a parent where the conversion isn't offered. 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-1-github-tag.ymlin steamify-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. Never work directly onmainor arelease/*branch either: edits, even uncommitted ones, go on afeature//bugfix/branch (create it before editing; the release branch only gets the version-bump commit and merged PRs). Don't push a newrelease/*branch before it's needed: a push can start CI builds (a dev ISO). A machine without a git identity: pass the repo's author per command (git -c user.name=... -c user.email=... commit, taken fromgit log) instead of changing the git config. A.no-release-yetfile at the root of a feature/bugfix branch means it must not be merged into the release branch yet (work in progress, untested on hardware). When the feature is complete, ask the user whether it may be released; on a yes, delete the file, then open the pull request and merge it. 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 screenshots (
assets/screenshot-menu-steam-machine.pngandassets/screenshot-menu-nvidia.png, side by side in the README): the app from the branch on a Steam Machine and on a PC with an NVIDIA card, every row visible and each window only as tall as its options need, the header showing the newVERSION, 1600 px wide. Size the window with a KWin script (match thesteamify-uiclass exactly: a title match also hits Konsole and editor windows) and crop the full-screen capture to the client rectangle; mind the display scale (the Steam Machine runs at 1.75). - 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.