Skip to content

[WIP] Omarchy Support - #19

Open
CS-5 wants to merge 19 commits into
mainfrom
omarchy
Open

[WIP] Omarchy Support#19
CS-5 wants to merge 19 commits into
mainfrom
omarchy

Conversation

@CS-5

@CS-5 CS-5 commented Aug 30, 2026

Copy link
Copy Markdown
Owner

No description provided.

CS-5 added 19 commits August 28, 2026 15:03
Adds the two data flags the rest of the Omarchy work hangs off, and
restructures the shell configuration so that files another owner also
writes to never have to be contested.

Detection:

- isOmarchy reads ID=omarchy from /etc/os-release. Keying on the distro
  ID rather than on a path makes it false by construction in WSL
  (ID=arch/ubuntu), dev containers, macOS and Windows, so no
  Omarchy-only file can leak onto another host.
- isThinkPad reads /sys/class/dmi/id/product_family, so the hardware
  pieces landing in later commits gate on the machine rather than on the
  distro.

Drop-in directories:

- ~/.bashrc and ~/.zshrc become four-line loaders over
  ~/.config/sh/rc.d/*.sh and should not need to change again. The
  content they held is now one numbered file per concern, with
  00-shell.sh setting DOTFILES_SHELL so the rest can hand the right name
  to `starship init`, `mise activate` and friends. That granularity is
  what lets a single .chezmoiignore line drop exactly the pieces a host
  already provides -- on Omarchy, starship/mise/zoxide/history, all four
  of which its own default/bash/rc already sets up.
- On Omarchy the loader sits below Omarchy's own /etc/skel/.bashrc
  preamble, kept byte-for-byte: env-bootstrap has to run above the
  interactivity guard or `bash -lc`, SSH commands and
  omarchy-launch-floating-terminal (a `bash -c`) lose OMARCHY_PATH and
  the mise/~/.local/bin PATH entries.
- The mise tool pins move from ~/.config/mise/config.toml to
  conf.d/10-dotfiles.toml. Omarchy provisions 13 lazy tool wrappers via
  `omarchy mise install`, and each runs `mise use -g` on every
  invocation, which rewrites config.toml; mise structurally refuses to
  write into conf.d, so chezmoi and Omarchy stop clobbering each other.
  Because config.toml outranks conf.d, the Omarchy branch drops the
  tools Omarchy already ships rather than pinning versions that would be
  silently ignored -- herdr especially, where Omarchy has a migration
  deleting mise copies because a stale client shadows /usr/bin/herdr
  with an older wire protocol. A run_once script moves a pre-existing
  dotfiles-owned config.toml aside, recognising it by its
  minimum_release_age line so a `mise use -g` config survives untouched.

Also on Omarchy: ~/.config/git/config is removed (git reads it before
~/.gitconfig, so its personal user.email and defaultBranch=master leak
through on every key the dotfiles leave unset, and Omarchy writes it
only on first run); ~/.config/ghostty/config is left unmanaged, since it
is byte-identical to Omarchy's default and leaving it alone keeps
`omarchy font set` and the theme system drift-free; and settings.json
points Claude Code at the theme omarchy-theme-set regenerates on every
theme change.

Verified with render.sh: the non-Omarchy mise render resolves to the
same 28 tools as before, settings.json is byte-identical off Omarchy,
and the rendered Omarchy .bashrc sources cleanly under `bash -i` in a
throwaway HOME (OMARCHY_PATH set, drop-ins layered above Omarchy's rc).
…dget

Brings the desktop side of this machine into the repo. Everything here is
ignored off Omarchy, so it cannot reach a WSL box, a dev container or a
Mac.

- ~/.config/hypr in full. Only input.lua (caps:escape, natural scroll,
  T14 touchpad tuning, three-finger workspace swipe), looknfeel.lua
  (tighter gaps, softer corners) and monitors.lua (1.25 scale -- "auto"
  resolves to 1.5 on this 14" 1080p panel, which reads too large)
  actually differ from Omarchy's defaults. The stock files come along to
  keep the directory whole; the trade-off is that an Omarchy migration
  editing one of them shows up as drift, which is exactly the case the
  sync widget's localEdits counter is there to surface.
- shell.json: the bar layout with carson.agents in place of
  omarchy.agents, the third-party widgets, and omarchy.lock disabled.
- carson.agents, a clone of Omarchy's agents plugin whose bar label is
  Claude Code's current 5-hour session percentage rather than a bare
  glyph. Panel.qml.orig -- the pristine copy left behind by the clone --
  is deliberately not carried over. `omarchy plugin validate` passes on
  the result.
- The portland-head theme (colors sampled from the background
  photograph) and the omazed theme-set hook that follows it.

run_onchange_after_07-omarchy-theme.sh re-applies the theme once its
files land, keyed on a hash of the whole theme folder. Omarchy discovers
a user theme from the directory alone but only regenerates the derived
per-application colour files on a set/refresh, so this closes that gap:
it switches themes when the current one differs and refreshes in place
when it does not, then reloads Hyprland and rescans plugins. All of it
is skipped without a HYPRLAND_INSTANCE_SIGNATURE, so a TTY bootstrap or
a container apply is a no-op rather than a pile of errors.
…uchpad modprobe drop-in

The T14-specific hardware pieces. All three gate on `and .isOmarchy
.isThinkPad` rather than on the distro alone, so a future Omarchy
desktop does not get a laptop keyboard-LED service -- and so a WSL
install on this same ThinkPad, where the DMI check would also pass,
gets none of it.

- dot_local/src/kbd-backlight-auto/{kbd-backlight-auto.c,Makefile}. The
  compiled binary and the superseded .py prototype stay out of the repo.
- The systemd user unit. Only the unit file is managed; the
  default.target.wants/ symlinks belong to systemd, and checking them in
  would fight `systemctl disable`.
- system/etc/modprobe.d/99-thinkpad-touchpad.conf, outside root/ because
  chezmoi manages $HOME only.

Ordering follows chezmoi's before/after split so a broken build cannot
leave a half-applied machine:

- run_onchange_before_10 compiles first, out of a temporary copy of the
  source rather than ~/.local/src -- run_before runs before chezmoi has
  written that directory, so on a fresh machine it isn't there yet. With
  set -e a compile failure aborts the apply before any config is
  touched.
- run_onchange_after_05 installs the /etc file, comparing before it
  escalates so an unchanged apply never puts up a password prompt. sudo
  when there is a terminal to type into, pkexec otherwise (an apply
  triggered from the bar widget has no TTY, and Omarchy's polkit agent
  shows a dialog).
- run_onchange_after_06 reloads and enables the unit, skipping cleanly
  when there is no user bus.

Verified: the C source compiles clean with the Makefile's flags
(-Wall -Wextra, no warnings), and the content 05 embeds is byte-identical
to the file currently on this machine, so cmp short-circuits and no
escalation happens on a no-op apply.
.chezmoidata/omarchy.toml lists what this machine needs that isn't a
file: nine repo packages, two AUR packages, four of Omarchy's
opinionated installers, and the four third-party shell plugins
shell.json places on the bar. Source-only, so it is template data and
never lands in $HOME. To add something, add a line.

The installers are listed with a sentinel package rather than as bare
commands, because they do more than install: `omarchy install terminal
ghostty` also makes ghostty the Omarchy default and wires it into the
theme system. The plugin entries close a gap the plan hadn't covered --
shell.json references four git-cloned plugins, so without this a
restore-from-scratch came up with four dead bar entries.

run_before_00-omarchy-packages.sh installs whatever is missing, before
any file is written, so a fresh machine has the packages its config
refers to by the time the config lands.

Deliberately a plain run_ rather than run_onchange_. Every action is
presence-guarded, so on a settled machine this is a handful of
`pacman -Q` calls that exit silently -- whereas a run_onchange keyed on
the manifest hash records itself as done even on a run that had to skip
(no terminal for the sudo prompt, AUR unreachable) and then never
retries. Always-run plus cheap checks cannot get stuck half-applied.

Privilege handling: plugins are git clones needing none, so they are
installed either way. The rest takes a single `sudo -v` up front rather
than one prompt per package, and when there is no terminal to type into
it names exactly what it skipped and leaves the work for the next
interactive apply. `--enable` is deliberately not passed to `omarchy
plugin add`: it would edit shell.json, which chezmoi overwrites moments
later with its own copy.

Verified against command stubs: a fully-provisioned machine produces no
output and never invokes sudo; a bare machine adds the plugins, prompts
once, and then runs the package and installer steps in order.
One backend, three front ends, so the bar, the keyboard and the CLI can
never disagree about the state of the dotfiles.

`dotfiles-status [--fetch]` prints a one-line JSON summary:

  {"applyNeeded":3,"localEdits":1,"behind":2,"ahead":0,"uncommitted":4,"ok":true}

It follows three rules, because a bar widget polls it unattended: never
prompt (GIT_TERMINAL_PROMPT=0 turns a credential prompt into an
immediate failure rather than a hang), never block for long (the fetch
is wrapped in `timeout 15`), and never exit nonzero -- a failure sets
"ok":false and still prints valid JSON.

The two `chezmoi status` columns mean different things and the split
matters. Column 2 is an ordinary pending apply. Column 1 is a target
that changed after chezmoi last wrote it -- an Omarchy migration,
`omarchy refresh`, a hand edit -- and chezmoi refuses to overwrite one
of those without being told to. That is the only state needing a
decision rather than a command, so it is the only one the widget takes
urgent.

`dotfiles-sync` is a gum picker bare and fully non-interactive with a
subcommand (pull/apply/diff/re-add/commit/update/status), which is what
the widget and menu invoke. `--help` works before chezmoi is installed.

carson.dotfiles is modelled on the first-party omarchy.system-update
widget: invisible while there is nothing to do, so it costs no bar space
on a machine in step. Left click opens the picker, right pulls and
applies, middle re-checks. The pull deliberately runs in a floating
terminal rather than a silent Process -- it can stop on a conflict or an
out-of-band edit, and swallowing that would leave the bar quietly wrong.
`omarchy plugin validate` passes.

The post-update hook pokes the widget after `omarchy update`, since
migrations rewrite the very files under ~/.config/hypr and
~/.config/omarchy that chezmoi manages; that is exactly when the drift
counter is most likely to have moved, and the half-hourly poll would
otherwise be slow to notice. `omarchy-shell -q` makes it a no-op when
the shell isn't running, so it can never fail the update it hooks.

Verified against a real chezmoi driving a throwaway destination: an
empty destination reports applyNeeded 124; after apply it drops to the
14 excluded scripts; tampering with an applied file flips it to `MM` and
the counters to localEdits 1; `chezmoi apply` then refuses the overwrite
until forced, which is the behaviour the urgent colour advertises.
Omarchy does not merely prefer its update path, it enforces it: the
00-omarchy-update-guard libalpm hook aborts any direct `pacman -Syu`.
Both scripts previously did exactly that on Arch and would have failed
outright on this machine.

`update` now hands the whole system-package step to `omarchy update -y`,
which takes a snapshot, refreshes keyrings, upgrades packages, runs
migrations and post-update hooks, and owns the restart prompt. The
chezmoi step gained a guard of the same shape as the existing mise one,
but keyed on whether the binary is writable rather than on the distro:
`chezmoi upgrade` rewrites the binary in place, so a root-owned
package-managed copy can never self-upgrade, and testing writability
covers Omarchy, Arch's extra/ and Homebrew in one condition instead of
enumerating packagers. `mise upgrade` still runs after the pull --
`omarchy update` did run `mise up`, but that was before the freshly
pulled pins landed.

`install.sh` gains the same detection and three branches: bootstrap
packages go through `omarchy pkg add` (installs only what is missing, no
-Syu to be aborted); chezmoi comes from the Arch repos rather than the
curl installer, so `omarchy update` keeps it current thereafter (extra/
carries 2.72, past .chezmoiversion 2.71); and the Claude Code installer
is skipped, because Omarchy already provisions a mise wrapper for it and
the mise shims sit ahead of ~/.local/bin on PATH, so a native install
would just be shadowed.

fish is unchanged: it is installed and chsh'd on Omarchy like anywhere
else. SDDM launches `uwsm start` directly and uwsm sources
env-bootstrap itself, so the graphical session never goes through the
login shell.

Verified by running both scripts against command stubs and a synthetic
/etc/os-release: ID=omarchy takes the omarchy branches, ID=arch falls
through to pacman, ID=ubuntu to apt, and the chezmoi step flips between
upgrading and deferring purely on the binary's write bit.
`curl claude.ai/install.sh | bash` was the last hand-rolled binary
install in the bootstrap, and it was there on every platform. mise has
the tool as aqua:anthropics/claude-code, so it becomes one line in the
tool list like everything else: one place that records the version, one
update path (`mise upgrade`, driven by the pins the dotfiles pull), and
no install step that only some machines have run.

Declared alongside the other tools Omarchy also provides, so it lands
off Omarchy and is left to Omarchy's own `omarchy mise install claude`
wrapper there -- config.toml outranks conf.d, so a pin in our file would
be silently ignored on that host anyway.

Side effect worth noting: 04-claude-plugins used to no-op on a first
install, because install.sh installed Claude Code *after* chezmoi
applied. 01-mise-install now installs it earlier in the same apply, so
plugins are set up on the first pass rather than the second.

AGENTS.md gains the general rule this is an instance of: anything with a
mise backend goes in the tool list rather than getting its own
installer, with mise and chezmoi called out as the two bootstrappers
that necessarily predate it.
Dropping the six stock files (hyprland.lua, bindings.lua, autostart.lua,
hyprsunset.conf, xdph.conf, .luarc.json) and keeping input.lua,
looknfeel.lua and monitors.lua.

The earlier "keeps the directory whole" rationale had it backwards.
Omarchy's migrations decide what to touch by hashing: migration
1781485962 carries stock_input_sha and stock_bindings_sha constants and
rewrites a user's file only when it still matches one of them.

That makes the two cases opposites. A stock file under chezmoi is a
standing conflict -- Omarchy matches the hash, rewrites the file, the
next apply reverts it, and that repeats on every update for as long as
both sides care. A customized file is skipped by the migration outright,
so chezmoi is its only writer and there is nothing to fight over.

So managing the stock six bought nothing and cost the one thing the
whole layout is trying to avoid. They are byte-identical to what Omarchy
installs, so a rebuilt machine gets them from Omarchy regardless; the
restore story is unchanged.

This also puts the hypr directory back in line with the rule already
stated for ghostty and written into AGENTS.md: only manage a file that
differs from its $OMARCHY_PATH/config default.
`omarchy update` ends in omarchy-update-restart, which calls
`gum confirm "Linux kernel has been updated. Reboot?"` and, on yes, runs
omarchy-system-reboot immediately. `-y` does not prevent this. It
exports OMARCHY_UPDATE_UNATTENDED and its own comment describes that as
"a promise not to ask anything" -- but nothing under $OMARCHY_PATH ever
reads the variable, so the prompt appears regardless.

With that step first, any kernel or Hyprland upgrade could take the
machine down before `chezmoi update` had run, leaving an update that
reported success and had in fact skipped every dotfiles step. Moving it
to the end costs nothing: chezmoi and mise are both already installed by
then, and the two package-managed guards skip their self-updates
precisely because this step is what updates them.

Non-Omarchy ordering is untouched -- packages still go first there,
where the step neither prompts nor reboots.

Verified against command stubs on both hosts: ID=omarchy now emits
mise → chezmoi → pull → mise upgrade → omarchy update -y, and ID=arch is
byte-identical to its previous output.
Two bugs found auditing the cross-platform story.

dotfiles-sync's interactive picker calls gum, but gum was only ever
present on Omarchy, where it is a pacman package. Everywhere else
`dotfiles-sync` with no subcommand died on "gum is not installed". It
joins the tool list next to jq and gh, in the group Omarchy already
provides.

`clean` never got the Omarchy branch that `update` did, and both halves
of its pacman path are destructive there rather than merely untidy:

  - `pacman -Sc` empties the package cache. That cache is Omarchy's only
    offline downgrade path, which is why omarchy-update-pkg-prune keeps
    two versions per package with `paccache -rk2`. Emptying it takes the
    ability to roll back a bad update with it.
  - `pacman -Rns` on -Qtdq orphans, unattended. Omarchy reviews orphans
    instead: omarchy-update-orphan-pkgs lists them and asks, defaulting
    to no. Removing them silently undoes that.

Also adds shellcheck and shfmt to the tool list, on every platform, so
the repo's own linting is pinned and reproducible rather than depending
on whatever happens to be installed.

Verified against stubs: ID=omarchy takes paccache -rk2 plus the orphan
review, ID=arch is byte-identical to its previous output.
The repo had no automated checks. This adds ./test.sh, which is the
harness that was being run by hand during the Omarchy work -- and which
caught a real bug there: after the mise config moved to a conf.d
drop-in, three .chezmoiscripts still `include`d the old path, and every
apply would have failed. Rendering the templates did not catch it;
driving a real apply did.

Four stages, cheapest first:

  render  every *.tmpl against all five environments (omarchy-thinkpad,
          omarchy-desktop, macos, linux, devcontainer)
  apply   a real `chezmoi apply --exclude=scripts` into a throwaway
          destination per environment, asserting nothing is left
          unapplied afterwards
  lint    shellcheck and shfmt over every shell script, including
          templates rendered first so the guarded bodies get checked
  parse   every JSON/TOML/JSONC file, templated JSON in each environment

Nothing touches $HOME: each apply gets its own temp destination and its
own chezmoi persistent state.

CI runs it on Ubuntu and macOS, and adds the one thing test.sh
deliberately will not do -- a genuine `chezmoi init --apply` with
scripts enabled, in Debian and Arch containers, as an unprivileged user.
That job then applies a second time and fails if `chezmoi status` is
non-empty, which is what would catch a script that rewrites a managed
file or a run_onchange whose hash is not stable.

A previous workflow was removed in a5b1068 as overkill, and it was: two
runners doing `chezmoi init --apply` with no assertions. The value here
is in the assertions and the environment matrix, neither of which that
had.

The formatting commit folded in here is shfmt's doing over five files
that were already inconsistent with the rest (trailing whitespace,
spacing inside arithmetic). shfmt runs with the repo's existing case
style rather than -ci, so the check matches how these scripts were
already written instead of demanding a reflow.
Three related gaps on the Windows side.

The drop-in reorganization covered bash and zsh but left the PowerShell
profile a monolith duplicating PATH, mise, starship and zoxide setup. It
now loads ~/.config/pwsh/rc.d/*.ps1 through the same four-line loop, and
the profile itself is one line.

Windows had no `update` and no `clean`. .chezmoiignore drops
.local/bin there, correctly -- those are bash -- but nothing replaced
them, so a Windows machine could not update itself at all. They arrive
as PowerShell functions in 60-commands.ps1, alongside set-work-email
which moves out of the profile.

Renumbering surfaced a latent ordering bug in the bash/zsh drop-ins:
mise was at 41, after starship at 40, so `command -v starship` was
evaluated before mise's shim directory reached PATH. On a host where
mise is the only thing providing starship, zoxide or mcfly, their setup
was silently skipped -- not just on first run, but every shell. mise
moves to 25, ahead of every tool activation, in both shells. fish was
already correct by accident: conf.d loads alphabetically and mise sorts
before starship and zoxide.

test.sh grows a PowerShell stage. There is no shellcheck for pwsh, but
its own parser rejects a syntax error, which is the failure that matters
for files nothing on this machine ever loads; GitHub's runners ship
pwsh, and the stage skips cleanly without it. Verified it is not a
no-op by feeding it a deliberate parse error and watching it fail.
`fish -c "fundle install"` only ever ran from install.sh, which is
Linux-only. macOS applies chezmoi directly, so every plugin listed in
config.fish -- bass, fzf.fish, autopair -- silently never installed
there. Moving it into a run_onchange script keyed on the fish config
hash fixes that and also picks up plugin-list changes on any host,
which the one-shot call in install.sh never did.

Network failures warn instead of aborting the apply; the next apply
retries.

The other half of the macOS gap does not have a clean fix and is now
documented rather than papered over. The per-host signing key cannot
move into a chezmoi script: gitSigningKeyFile resolves when
.chezmoi.toml.tmpl renders, which is `chezmoi init` time, before any
script runs -- so a script that generated the key would not be reflected
until the next init. It stays in install.sh, and the README tells macOS
users to run scripts/generate-signing-key.sh beforehand if they want a
key on disk rather than the agent-based fallback (which does work).
Making fish the login shell is likewise noted as manual there.
The bootstrap job asserted that `chezmoi status` is empty after a second
apply, which would have failed on every run. Plain run_ and run_after_
scripts are always-run by design, so chezmoi lists them in status even
immediately after executing them -- three of them here
(install-claude-config, mise-update.sh, mise-update.ps1), plus the
Omarchy package script on that host.

Their presence says nothing about idempotency. The assertion that
actually matters is that no managed FILE is left out of sync, which is
what catches a script rewriting something chezmoi owns or a
run_onchange whose hash is not stable. Filter script rows out and assert
on the rest -- the same thing test.sh's apply stage already does.

Validated by running the real sequence locally: two full applies with
scripts enabled, in a sandbox where HOME and every external command were
redirected, leaves no file out of sync and exactly the three always-run
scripts listed.
cloud-init.yaml carried a seven-package bootstrap list that install.sh
installs again moments later -- two places to keep in step, for no gain.
It now installs only what cloud-init itself needs to fetch the repo
(ca-certificates, curl, git) and leaves the rest to install.sh.

That exposed a real gap rather than just duplication: build-essential
was in cloud-init's list and nowhere else, so a Debian or Arch host
provisioned by install.sh directly had no compiler at all -- mise builds
some tools from source, and on Omarchy the keyboard-backlight daemon is
compiled during the apply. install.sh now installs a toolchain in each
branch, named per distro since build-essential and base-devel do not
share a name.

.editorconfig encodes the conventions the repo already follows, so an
editor agrees with what ./test.sh enforces (shfmt -i 4, default case
indentation) without depending on the VS Code settings. Two carve-outs:
*.tmpl keeps trailing whitespace, because Go templates control their own
rendering, and the vendored fundle.fish is left byte-identical so it
stays diffable against new upstream releases.
dotfiles-status gains a "files" array -- path, status code, added,
removed, binary -- and the bar widget lists it on hover, capped by a new
tooltipFiles setting, with a `!` against anything edited outside
chezmoi. Enough to see what an apply would do without opening a
terminal.

Counts come from parsing a single `chezmoi diff` rather than shelling
out to git, so it works outside a work tree. Two things that parse
caught, both verified against `git apply --numstat`:

  - Matching removed lines with /^--- / misreads a deleted Lua comment
    as a diff header. The hypr configs are full of `-- like this`, so
    input.lua reported 3 removals where git said 4. Fixed by counting
    only inside hunks, which excludes headers structurally.
  - chezmoi emits directory entries as mode 40xxx with no line diff.
    On a fresh machine those were 39 of 112 rows, all `+0 -0`, pushing
    real files out of the tooltip. Now dropped from the list; the
    counters still include them, since creating a directory is a real
    pending change.

Cost is one extra `chezmoi diff` per poll: 60ms with drift, 258ms on a
fresh machine with 112 changes, and binaries are summarized rather than
embedded so the payload stays ~10KB.

Verified by running the tooltip's real QML through a Qt engine against
live dotfiles-status output, in both the few-files and truncated cases.
Machines should be able to look different while sharing these dotfiles.
Theme *definitions* stay managed -- portland-head's colors.toml and
background are still applied, so every machine has it in the switcher --
but nothing here decides which theme is selected.

07-omarchy-theme.sh previously ran `omarchy theme set portland-head`
whenever the current theme differed, which was actively wrong: this
machine is on Catppuccin Latte, and the next apply would have switched
it back.

The script still has one job, because Omarchy compiles a theme's
colors.toml into per-application files (ghostty.conf, claude.json,
btop.theme, ...) at set/refresh time rather than on demand -- so a
colors.toml that chezmoi just changed leaves those derived files stale.
It now refreshes if and only if the changed theme is the one active on
this machine, via `omarchy theme refresh`, which re-runs theme-set
against the current selection and never changes what is selected. Any
other active theme is left alone with a log line saying so.

The managed theme list is enumerated from the source tree at render
time, so dropping in a second custom theme needs no edit here --
verified by adding one and watching it appear.

Nothing else pins a theme: the active selection lives in
~/.local/state/omarchy/current/theme.name, which is not managed, and the
two theme-adjacent pieces that are managed both follow the live theme
rather than naming one -- the omazed hook (Zed) and the
"theme": "custom:omarchy" key in settings.json (Claude Code).

Verified against stubs across all four paths: a managed theme active
refreshes, an unmanaged one is left alone, no graphical session is a
no-op, and a newly added theme is picked up automatically.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant