Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Hyprland .conf → Lua migration guide

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.


Quick start — run it with an AI agent

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-migration

OpenCode (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 version

Claude 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.


0. Background — why and when

  • 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.lua exists, it is loaded INSTEAD of hyprland.conf. The mere existence of the file flips the config type.
  • The format check happens at Hyprland launch only. hyprctl reload never 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.

1. Facts verified against Hyprland v0.56.2 source

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)

2. Pre-flight (on the machine to migrate)

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)

3. Inventory the config (decision points — your config differs)

Read everything under ~/.config/hypr/ and note:

  1. 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.
  2. Variables shared across files: hyprlang $vars are global across the whole parse; files that are sourced more than once (e.g. a colors.conf sourced 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).
  3. Plugins: plugin { } blocks are the converter's weak spot — they come out as -- TODO comments and need fully manual translation.
  4. External apps that auto-write config files (e.g. an app that drops a *-binds.conf into your config dir and asks you to source it). You cannot convert those once — the app will keep rewriting them in hyprlang. Use the bridge pattern in step 8.
  5. Scripts that parse your conf (anything grepping hyprland.conf). Rare, but they'll need updating.

4. Backup

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.

5. Install the converter

Arch-family package managers (EndeavourOS: yay by default; CachyOS: paru):

yay -S hyprlang2lua        # Go tool by EIonTusk — the one this guide assumes

Alternatives: 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).

6. Convert

cd ~/.config/hypr/config
hyprlang2lua --dir . --report

Do 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.

7. Fix converter output + restructure

7.1 Known converter bugs (all observed on a real config)

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 .. ")"

7.2 Shared variables: the SH table pattern

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 .. ")" })

7.3 Entry file + load order

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 startup

8. Externally-managed config files: the bridge pattern

If 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).

9. ⚠️ Switchover safety rule

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   # ← arm

Never leave a half-fixed tree with a live entry file; an unexpected session restart would load it.

10. Validate BEFORE activating

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.txt

compare_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.

11. Activate + verify

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

12. Rollback (at any point)

mv ~/.config/hypr/hyprland.lua ~/.config/hypr/hyprland.lua.disabled
# log out/in → the untouched .conf tree loads again

Last 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.


Reference layout

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.

Sources

About

Agent-executable guide + validation tools to migrate a Hyprland config from hyprlang .conf to Lua before 0.57 drops it

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages