15 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.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. Bump
FEATURE_VERSION[<id>](lib/menu.sh) whenever what<id>_enablesets up changes: installs recorded with an older number are ticked and re-applied ("update" in the plan and the app). New default sub-options are ticked for installs whose parent is on (feature_new). 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. 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
- Every PR that should be released bumps
VERSIONand adds its section. A push tomainpublishes 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. - 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). 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): a sub-option ofmachine(likekpinandbios), only on Fremont with the pinned kernel (stays visible while on); untickingkpinunticks it. It needs someone at the screen. Terminal: every step needs a "y" within 15 s (WIZARD_HDMI_CONFIRM_SECONDSfor scripted tests). App: its own screen (hdmi-options,hdmi-try,hdmi-resetbackend commands, the EDID cached in$XDG_RUNTIME_DIR/steamify-hdmi), thenapply --hdmi <output>=<w>x<h>:<rates>; without--hdmithe backend refuses it. debugfs is root-only (glob it under sudo), andedid_overridetakes exactlyresetwith no newline. Build the EDID from the DDC read, not from sysfs: a live override replaces the kernel's copy. EDIDs are saved per display (steamify-<id>.bin, id = EDID bytes 8-17, listed in/etc/steamify/hdmi-edid.conf), never on the kernel command line (that applied to any display on the port):steamify-edid-hotplug(boot unit + udev drm hotplug rule) loads the connected display's file, else resets. It records what's loaded in/run/steamify-edidbefore its owntrigger_hotplug, whose event runs it again.hdmi_statusis on only while the connected display is boosted; disable forgets only that display (kpin_disableforgets all). The app manages the list (hdmi-forget,hdmiDisplaysin status). Pre-2.1.0 setups are moved byhdmi_migrate. -
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. -
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 (andhdmi, which needs the pinned kernel), so a normal run removes an existing pin. 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. -
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.