Step-by-step methodology for migrating a Hyprland configuration from the legacy
hyprlang .conf format to the Lua format introduced in Hyprland 0.55,
validated end-to-end on a real machine (Hyprland 0.56.2, ~470-line modular
config, 95 binds). reference/ holds a sanitized, genericized version of
that migration pair.
Written for execution by an AI agent (or a careful human) on any Arch-based machine: CachyOS, EndeavourOS, plain Arch, etc. Your config will differ from the reference — the methodology is the deliverable, not the diffs.
This repo is not a script you execute; it is a methodology an AI coding
agent follows on the machine whose Hyprland config is being migrated. The
agent reads README.md (this file) and AGENT-PROMPT.md, works on
~/.config/hypr, and validates before activating. You stay at the keyboard:
approve tool permissions when asked, and do the final logout/login yourself
(a session restart is required to switch formats — hyprctl reload won't).
On the machine to migrate, clone the repo and launch your agent of choice
from inside the repo (so it can read the guide and tools/):
git clone https://github.com/MrChausson/hyprland-lua-migration.git
cd hyprland-lua-migrationOpenCode (non-interactive run; it may still ask you to approve tool permissions during the session):
opencode run "$(cat AGENT-PROMPT.md)"GitHub Copilot CLI (interactive-first tool — GA since Feb 2026; --prompt
is documented alongside --agent= and may not apply standalone, so the
reliable path is to start a session and paste the prompt):
copilot # then paste the contents of AGENT-PROMPT.md
copilot --prompt "$(cat AGENT-PROMPT.md)" # worth trying first on your versionClaude Code (interactive with the prompt as the opening message):
claude "$(cat AGENT-PROMPT.md)"Any other agentic CLI works too — the only requirement is an agent that can
read files and run shell commands locally. Read AGENT-PROMPT.md yourself
first so you know what the agent has been asked to do.
- Since 0.55, Hyprland supports a Lua config (
~/.config/hypr/hyprland.lua). Announcement: https://hypr.land/news/26_lua/ - The old hyprlang syntax is supported for only 1–2 releases after 0.55 and is removed in 0.57. On 0.56 you get a deprecation warning at startup.
- If
hyprland.luaexists, it is loaded INSTEAD ofhyprland.conf. The mere existence of the file flips the config type. - The format check happens at Hyprland launch only.
hyprctl reloadnever switches formats — testing requires a full logout/login. - Migrating on 0.56 is the safe window: both formats work, and rollback is one file rename.
- hyprpaper / hypridle / hyprlock keep hyprlang — no migration needed for them.
Do not re-research these; they were verified by reading the tagged source
(src/config/lua/ConfigManager.cpp, src/config/lua/bindings/LuaBindingsToplevel.cpp):
| Fact | Consequence |
|---|---|
package.path gets <configdir>/?.lua and <configdir>?/init.lua prepended |
require("config/keybinds") resolves for a module at ~/.config/hypr/config/keybinds.lua |
Lua state is initialized with luaL_openlibs (full stdlib; only debug.sethook removed) |
os.getenv, io.open etc. are usable in config code (needed by the external-file bridge below) |
parseKeyString has an explicit branch for code:N keycodes |
hl.bind("SUPER + code:10", ...) works verbatim |
mouse:N is a recognized "special sym" |
hl.bind("SUPER + mouse:272", ...) works |
hl.env(name, value, dbus?) — 3rd boolean is the dbus export |
hyprlang envd = K,V → hl.env(K, V, true); plain env = K,V → hl.env(K, V) |
| Local API reference | /usr/share/hypr/stubs/hl.meta.lua (editor stubs, exact API surface) and /usr/share/hypr/hyprland.lua (official example) |
Official Lua API shape (from the shipped example):
hl.config({ general = { gaps_in = 3 }, decoration = { rounding = 4 } }) -- nested tables
hl.monitor({ output = "DP-1", mode = "1920x1080@60", position = "0x0", scale = "1" })
hl.bind("SUPER + Q", hl.dsp.window.close(), { description = "..." }) -- opts: locked, repeating, mouse, description...
hl.window_rule({ match = { class = "^(firefox)$" }, float = true })
hl.workspace_rule({ workspace = "1", default = true, monitor = "DP-1" })
hl.layer_rule({ match = { namespace = "waybar" }, animation = "slide down" })
hl.env("XCURSOR_SIZE", "24")
hl.curve("overshot", { type = "bezier", points = { { 0.13, 0.99 }, { 0.29, 1.1 } } })
hl.animation({ leaf = "windowsIn", enabled = true, speed = 4, bezier = "overshot", style = "slide" })
hl.gesture({ fingers = 3, direction = "up", action = "fullscreen" })
hl.on("hyprland.start", function() hl.exec_cmd("waybar &") end) -- exec-once equivalent
hl.define_submap("resize", function() hl.bind("escape", hl.dsp.submap("reset")) end)hyprctl version # must be 0.55 or 0.56 — do NOT attempt on 0.57+
hyprctl configerrors > ~/baseline-configerrors.txt
hyprctl binds > ~/baseline-binds.txt # raw dump — used for comparison in step 10
grep -c '^\tkey:' ~/baseline-binds.txt # your bind count (baseline)Read everything under ~/.config/hypr/ and note:
- The
source=graph: which file is the entry (hyprland.conf), what it sources, and what those files source. This determines your Lua module structure and load order. - Variables shared across files: hyprlang
$varsare global across the whole parse; files that are sourced more than once (e.g. acolors.confsourced by two other files) exist precisely to share variables. In Lua,locals do not cross module boundaries — this is the #1 thing to design (see step 7). - Plugins:
plugin { }blocks are the converter's weak spot — they come out as-- TODOcomments and need fully manual translation. - External apps that auto-write config files (e.g. an app that drops a
*-binds.confinto your config dir and asks you tosourceit). You cannot convert those once — the app will keep rewriting them in hyprlang. Use the bridge pattern in step 8. - Scripts that parse your conf (anything grepping
hyprland.conf). Rare, but they'll need updating.
cp -r ~/.config/hypr ~/.config/hypr.bak-lua-migration-$(date +%F)The old .conf files are never modified during migration, so this backup plus
the untouched originals give you two rollback layers.
Arch-family package managers (EndeavourOS: yay by default; CachyOS: paru):
yay -S hyprlang2lua # Go tool by EIonTusk — the one this guide assumesAlternatives: pipx install hyprconf2lua, or the in-browser converter at
https://eiontusk.github.io/hyprlang2lua/. There is no official converter
(upstream maintainer declined to ship one).
What hyprlang2lua handles well: all bind flag variants (bindd descriptions,
bindel → locked+repeating opts, bindm), submaps → hl.define_submap,
exec-once → bundled hl.on("hyprland.start", …), $var → local var +
concatenation chains, match: window rules, nested categories → nested tables,
hyphenated option names → underscored keys.
Useful flags: --report (coverage stats), --check (exit 3 if anything was
flagged -- TODO), --dir DIR (convert a whole tree, writing *.lua next to
each *.conf).
cd ~/.config/hypr/config
hyprlang2lua --dir . --reportDo NOT convert externally-managed files (step 3.4) — they get a bridge instead. Convert only files that are yours.
Expect ~90–100% coverage. Everything flagged -- TODO: manual review goes on
your fix list, along with the systematic bugs below.
| Bug | Symptom in output | Fix |
|---|---|---|
source= lines become stale requires |
require("config.colors") with a "convert this file" comment |
Delete them; load order is handled by the entry file. Locals wouldn't cross modules anyway |
Unexpanded $var in strings |
hl.exec_cmd("$idlehandler"), border_color = "$accent_blue" |
Replace with references to the shared table (7.2) |
| Multi-condition matches mangled | class = "^()$ match:title ^(PiP)$" |
Split: class = "^()$", title = "^(PiP)$" |
| Composite matches mangled | float = "1 match:workspace r[1-10]" |
Split: float = 1, workspace = "r[1-10]" |
| Layer rules broken | match = { namespace = "animation slide top" } + TODO |
Rebuild: hl.layer_rule({ match = { namespace = "logout_dialog" }, animation = "slide top" }) |
| Dispatcher arg artifact | hl.dsp.layout("togglesplit, ") |
Strip trailing ", " → ("togglesplit") |
$var in bind descriptions not expanded |
description = "... ($terminal)" — hyprlang expanded these live |
Concatenate: " (" .. terminal .. ")" |
Merge every file that existed to provide variables (colors, app commands) into
one config/shared.lua that sets a global table, loaded first:
-- config/shared.lua
SH = {
mainMod = "SUPER",
accent_blue = "rgba(5b9bf5ff)",
terminal = "alacritty",
applauncher = "wofi",
idlehandler = "swayidle -w timeout 600 'swaylock -f -c 000000' before-sleep 'swaylock -f -c 000000'",
}Each module then aliases what it needs at the top (keeps converted bodies untouched):
-- config/keybinds.lua
local mainMod, terminal = SH.mainMod, SH.terminal
hl.bind(mainMod .. " + T", hl.dsp.exec_cmd(terminal), { description = "Terminal (" .. terminal .. ")" })Hand-write hyprland.lua. Module order must mirror the old source= order
— exec-once lines become hl.on("hyprland.start", fn) callbacks and run in
registration order, so order preservation keeps autostart semantics identical.
require("config.shared") -- first: defines global SH
require("config.animations")
require("config.autostart") -- … same order as the old hyprland.conf sources
-- …
pcall(require, "config.assistant_bridge") -- optional pieces: pcall so absence never breaks startupIf an app (launcher helper, assistant, etc.) keeps rewriting a .conf fragment
inside your config dir, do not convert it — it will drift back. Read it at
config-load time and register whatever it contains:
-- config/assistant_bridge.lua — bridge for an app-managed hyprlang fragment
local home = os.getenv("HOME")
if not home then return end
local path = home .. "/.config/hypr/assistant-binds.conf"
local ok, file = pcall(io.open, path, "r")
if not ok or not file then return end
for line in file:lines() do
-- supports: bind = MODS, KEY, exec, command (flag variants like bindel too)
local mods, key, cmd = line:match("^%s*bind%a*%s*=%s*([^,]-)%s*,%s*([^,]-)%s*,%s*exec%s*,%s*(.+)%s*$")
if mods and key and cmd and key ~= "" then
local keyspec = mods:gsub("%s+", " + "):upper() .. " + " .. key
hl.bind(keyspec, hl.dsp.exec_cmd(cmd))
end
end
file:close()Adapt the pattern if the fragment contains other directives. os/io are
available (verified, see step 1).
The existence of ~/.config/hypr/hyprland.lua alone switches your config at
the next Hyprland launch. While you are still editing/validating modules,
keep the entry parked under another name:
mv ~/.config/hypr/hyprland.lua ~/.config/hypr/hyprland.lua.pending
# … all validation passes …
mv ~/.config/hypr/hyprland.lua.pending ~/.config/hypr/hyprland.lua # ← armNever leave a half-fixed tree with a live entry file; an unexpected session restart would load it.
Three layers, cheapest first:
# 1. Syntax
for f in ~/.config/hypr/config/*.lua ~/.config/hypr/hyprland.lua.pending; do luac -p "$f" || echo "FAIL $f"; done
# 2. Runtime semantics against a stub hl (catches nil refs, mangled rules, raw '$')
lua5.4 /path/to/repo/tools/mock_hl_test.lua # prints a report, exits non-zero on errors
lua5.4 /path/to/repo/tools/mock_hl_test.lua --dump > /tmp/mock_binds.txt
# 3. Bind keyset identical to the live session (the strongest check)
/path/to/repo/tools/compare_binds.sh ~/baseline-binds.txt /tmp/mock_binds.txtcompare_binds.sh normalizes both sides (modifier bitmask math, code:N
keycodes, mouse:N keys) and diffs the sets. Zero diff = every bind from
your old config exists in the new one with the same key combo.
For step 2, extend mock_hl_test.lua with your own assertions (option paths
that used to be $vars are the important ones — a nil there silently drops the
key from the table instead of erroring). Examples are included in the file.
mv ~/.config/hypr/hyprland.lua.pending ~/.config/hypr/hyprland.lua
# LOG OUT AND BACK IN (a full session restart; hyprctl reload will NOT switch formats)After relogin:
hyprctl configerrors→ empty (compare with baseline file)- bind count matches baseline (
hyprctl binds | grep -c '^\tkey:') hyprctl monitors→ correct outputs/modes/scales- the 0.56 deprecation warning is gone
- manual smoke test: terminal & launcher binds, workspace digit keys, any submaps, volume/brightness keys and OSDs, keyboard layout toggle, float rules (open a window that should float), autostart apps present, animations and blur/rounding look unchanged, external-app bridge bind works
mv ~/.config/hypr/hyprland.lua ~/.config/hypr/hyprland.lua.disabled
# log out/in → the untouched .conf tree loads againLast resort: restore the step-4 backup. Keep the .conf files and the backup
around for at least a week of daily use before deleting anything; optionally
stamp the old files with a # MIGRATED to *.lua — no longer loaded banner once
you trust the new config.
See reference/before/ and reference/after/ for the sanitized, genericized
example pair this guide was distilled from (modular config with sourced
variable files, a submap, code:N binds, and an externally-managed binds
file). Your machine's config will differ — inventory first (step 3), then
adapt.
- Announcement: https://hypr.land/news/26_lua/
- Wiki (current, Lua): https://wiki.hypr.land/Configuring/Start/
- Converter: https://github.com/EIonTusk/hyprlang2lua (AUR:
hyprlang2lua) - Alternative converter: https://github.com/Prateek-squadron/hyprconf2lua
- Upstream discussion (no official tool planned): hyprwm/Hyprland#14463
- Facts in step 1 verified against the
v0.56.2tag of hyprwm/Hyprland source