Profiles: machines, roles and shared layers

The repo was one machine's capture. It is now a set of profiles built from
layers under profiles/:

  machines/  hp-laptop, macbook-air-m2, coder-desktop
  roles/     developer, devops, ai-agents
  features/  handoff (lan-mouse)
  shared     base, personal, personal-desktop

A profile resolves to its includes (depth first), any --roles, then itself;
for a path several layers provide, the last wins. install.sh --show prints
the layers and which one owns each file.

Hyprland settings no longer share a file: base's hyprland.lua loads
~/.config/hypr/layers/*.lua with Omarchy's own require_all, and bindings.lua
stays stock so nothing binds twice. Verified with Hyprland 0.56.2
--verify-config for all three profiles, plus a broken probe layer to prove
the directory is loaded.

packages.txt gains per-line arch (Arch Linux ARM lacks opencode, codex, mise,
shellcheck) and cmd= (skip when dotfiles' ~/.local/bin already provides the
command), and an [ignore] section that replaces capture.sh's hard-coded list.
Tab-separated records would have collapsed empty fields under read, so
package records use the unit separator.

hp-laptop: re-captured. monitors.lua catch-all scale is now 1, shell.json
has four new plugins, fix-stuck-displays has its connector-name fix,
navbar-cat is gone, plugin-manager and omarchy-phone are pinned, the
herdr-hud working tree is re-diffed and bin/herdr-web rides along. Its
handoff peer is now the Air (192.168.1.49); the old default list was the
HP's own addresses, written from the Air's side.

macbook-air-m2: captured read-only over SSH as it is (no themes, plugins
or bar changes). Its LAN_MOUSE_POINTER_SCALE=0.7 moves from the shared unit
into a lan-mouse.service.d drop-in in its own layer.

coder-desktop: the Omarchy settings covert-cloud's image used to patch in
by hand (Virtual-1 at 1920x1080, no animations, stay-awake, Retro 82 once),
for the image to apply at a pinned commit. Roles reach it per workspace via
install.sh --roles ... --no-clobber.

capture.sh writes each file back into the layer that owns it, and refuses
to run before the first layered install unless --force, since the old file
shapes would land in shared layers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153egowV1Xw2RNjZnSyZicc
This commit is contained in:
2026-10-16 16:01:54 -04:00
co-authored by Claude Opus 5.5
parent b3f3f46b98
commit 87a31858fd
98 changed files with 2010 additions and 532 deletions
+260 -113
View File
@@ -1,67 +1,185 @@
# omarchy-setup # omarchy-setup
Every customization this laptop has on top of a stock Omarchy install, in one Omarchy customizations on top of a stock install, as **profiles**: one per
place, so a second machine can be brought to the same state. machine, each built from shared **layers** and generic **roles**.
Captured from Omarchy **4.0.4-1** on **2026-10-10**. | Profile | Machine |
|---|---|
| `hp-laptop` | The HP (`omarchy-laptop`): Intel, Anker dock, LG displays. Where this repo started. |
| `macbook-air-m2` | The Air (`omarchy-air`): Asahi Linux on Arch Linux ARM, aarch64. |
| `coder-desktop` | The streamed Coder desktops, built by covert-cloud's image pipeline. |
| Role | Adds |
|---|---|
| `developer` | gh, delta, uv, just, direnv, shfmt/shellcheck, httpie, mise, VS Code |
| `devops` | kubectl, helm, k9s, kubectx, stern, kustomize, talosctl, cilium, tofu, terragrunt, packer, ansible, go-task, yq, sops/age, dig/mtr/nmap/tcpdump |
| `ai-agents` | Claude Code, OpenCode, Codex, Gemini CLI, Copilot CLI |
```bash ```bash
git clone <this repo> ~/omarchy-setup git clone https://git.nic-oconnor.com/public/omarchy-setup.git ~/omarchy-setup
cd ~/omarchy-setup cd ~/omarchy-setup
./install.sh --dry-run # see what it would change ./install.sh --profiles # what exists
./install.sh # do it ./install.sh --profile hp-laptop --show # its layers, and which layer owns each file
./install.sh --dry-run # see what it would change (profile picked by hostname)
./install.sh # do it
./install.sh --roles devops,ai-agents # add roles on top of the profile
``` ```
`install.sh` is idempotent and backs up anything it overwrites as `install.sh` is idempotent and backs up anything it overwrites as
`<file>.bak.<epoch>`. `capture.sh` runs the other way, pulling live changes back `<file>.bak.<epoch>`. It records the profile and roles it applied in
into the repo so this stays the source of truth. `~/.config/omarchy-setup/profile`, so later runs (and `capture.sh`) need no
flags. `capture.sh` runs the other way, pulling live changes back into the
layer that owns each file, so the repo stays the source of truth.
## What is in here ## How profiles are built
| Path | What | Everything lives under `profiles/`. A layer is a directory with a `profile`
file (bash: `description`, `include=(...)`, and for machines `hostnames`,
`arch`, `handoff_peer_ips`) and any of the same pieces this repo always had:
| Path in a layer | Installed to |
|---|---| |---|---|
| `config/` | Mirrors `~/.config`. Only files that differ from Omarchy's shipped defaults. | | `config/` | `~/.config`, mirrored |
| `themes/` | The three hand-built themes, wallpapers included. | | `themes/` | `~/.config/omarchy/themes` |
| `plugins.txt` | The 10 Omarchy shell plugins, each pinned to a commit. | | `plugins.txt`, `plugin-patches/` | `~/.config/omarchy/plugins`, pinned; local work replayed |
| `plugin-patches/` | Local plugin work that exists on no remote (see below). | | `bin/` | `~/.local/bin` |
| `bin/` | `omarchy-handoff` and `fix-stuck-displays`. | | `systemd/user/` | user units, plus `<unit>.service.d/*.conf` drop-ins |
| `handoff/` | The lan-mouse source patch that fixes pointer feel, plus example `config.toml`s. Not a `~/.config` mirror. | | `system/` | `/etc`, mirrored (sudo) |
| `systemd/user/` | `lan-mouse.service`, `voxtype.service`. | | `handoff/` | the lan-mouse patch and example configs (`install.sh handoff`) |
| `system/` | `/etc` drop-ins: the ARP sysctl and `hid_apple` fnmode. | | `packages.txt` | `omarchy pkg add` / `omarchy pkg aur add` |
| `packages.txt` | Packages beyond the Omarchy baseline, each with a reason. | | `setup.sh` | run last, for state outside `~/.config` (markers, gsettings, a first-run theme) |
## The customizations, and why A profile resolves to an ordered list of layers: its `include`s first (depth
first, each once), then any `--roles`, then the profile itself. **For a path
several layers provide, the last one wins**, so a machine overrides the roles
and shared layers beneath it. `./install.sh --show` prints the result.
**Displays** (`config/hypr/monitors.lua`) are the fiddliest part. The file is the | Layer | Used by | What |
single source of truth for monitor rules on purpose — overlapping rules spread |---|---|---|
across several files is what previously made external displays unrecoverable. | `base` | all | `hyprland.lua` with the `hypr/layers/` loader; `bindings.lua` kept at stock |
Three outputs are named by EDID description rather than connector, so they match | `personal` | both laptops | branding, default agent (`claude`), herdr, opencode, voxtype |
whichever port they land on: the built-in Chimei panel at 1.25x, the LG IPS236 on | `personal-desktop` | HP | the three hand-built themes, the shell plugins, bar layout, Claude/Herdr bindings |
HDMI at 1x, and the LG TV through the Anker dock. The TV is deliberately left on | `features/handoff` | both laptops | `omarchy-handoff` + patched lan-mouse, its unit and binding |
`preferred` (3840x2160@30) — the dock negotiates DP alt-mode with only two lanes, | `roles/*` | per profile, or `--roles` | packages, and any bindings the role needs |
so forcing 4K60 produced a "monitor has no mode" loop. `GDK_SCALE=1` with a 1.25 | `machines/*` | — | displays, input, hardware packages, `/etc` drop-ins, per-machine overrides |
monitor scale; the stock pairing is 2 and `auto`.
`hyprland.lua` sources `hyprmoncfg-monitors.lua` last so the `hyprmoncfg` daemon's **Hyprland settings never share a file.** `base`'s `hyprland.lua` loads every
generated layout wins over anything above it. `~/.config/hypr/layers/*.lua` in name order (Omarchy's own `require_all`, with
reload), after `monitors`/`input`/`bindings`/`looknfeel`/`autostart`. Each
layer ships its own `NN-<layer>.lua` there — roles 20s, `personal-desktop` 40,
features 50, machines 80 — and `bindings.lua` stays stock so nothing is bound
twice. `install.sh` warns about a file in `hypr/layers/` that no layer of the
current profile provides (a role you dropped).
`bin/fix-stuck-displays` recovers the related kernel-side failure: after **Packages** lines are `name [arch...] [cmd=a,b] # why`, in `[repo]`, `[aur]`,
suspend/resume i915 can leave an unplugged connector "disconnected" but still `[optional]` or `[ignore]` sections. An arch limits a line to `x86_64` or
"enabled" on a pipe Hyprland has forgotten, which blocks every other output from `aarch64` (Arch Linux ARM lacks `opencode`, `openai-codex`, `mise` and
modesetting. It forces the connector on, disables it through Hyprland to release `shellcheck`). `cmd=` skips the package when one of those commands is already
the pipe, then returns it to auto-detect. It needs passwordless sudo for on PATH — on the laptops, `dotfiles`' `bin/` puts most devops and agent CLIs in
`tee /sys/class/drm/*/status`. `~/.local/bin`, and the Arch package would only duplicate them; on a Coder
desktop, which has no `dotfiles`, the package is the tool. `[ignore]` is for
packages a machine has on purpose but this repo does not manage, so
`capture.sh packages` stays quiet about them.
**Keyboard/mouse sharing** (`bin/omarchy-handoff`, `systemd/user/lan-mouse.service`) Roles name the `dotfiles` modules they pair with (`dotfiles=(...)`), and
drives `lan-mouse` to share input with the other Omarchy laptop, bound to `install.sh` prints them when `~/.dotfiles` is absent. It never runs
`SUPER+SHIFT+K` (`SUPER+K` is already Keybindings). The script's header comment `dotfiles` itself: that needs a 1Password token, which a Coder desktop does
is the real documentation — it records the non-obvious facts that cost hours: not have.
`capture_backend` must be `layer-shell` (the portal backend is a silent no-op on
Hyprland 0.56.2), `[authorized_fingerprints]` is keyed by fingerprint and the ## Coder desktops
reversed form fails silently, and the log variable is `LAN_MOUSE_LOG_LEVEL`, not
`RUST_LOG`. It runs as a user unit rather than from `autostart.lua` because The image is built by `covert-cloud`
`o.launch_on_start` sends both streams to `/dev/null`, hiding every pairing (`platform/workspaces/omarchy-desktop/image/`). Its `provision/user.sh` clones
failure. this repo at a **pinned commit** into `~/.local/share/omarchy-setup` and runs
`install.sh --profile coder-desktop`, which bakes in `base`, `developer` and
the `coder-desktop` layer:
- one fixed `Virtual-1` monitor at 1920x1080, GDK scale 1 — **must match**
`selkies.service`'s `--manual-width/--manual-height`; the build fails if the
line is missing;
- no window animations, and GTK reduced motion: the VM has no GPU, so every
frame is drawn on the CPU;
- Omarchy's stay-awake mode: the desktop user has no password, so a lock
screen could never be unlocked; the build checks for the marker;
- Retro 82 as the default theme, set once only, so a theme the developer picks
survives restarts. (The image itself installs the Covert Cloud themes.)
The plumbing stays in `covert-cloud`: Selkies, the Coder agent, the Super key
stand-in, the start page, autologin, and the lockdown (no SSH, no file
transfer, locked password). Nothing from `personal*` or `handoff` goes on a
desktop that other people use.
**Roles per workspace.** The Coder template has a **Roles** parameter
(multi-select: `devops`, `ai-agents`). At every workspace start it runs
`install.sh --profile coder-desktop --roles <chosen> --no-clobber`:
idempotent, so an unchanged choice costs a few seconds, and `--no-clobber`
keeps any file the developer has edited. Deselecting a role stops managing
it but does not uninstall its packages; its `hypr/layers/` file is left in place
and reported.
**Changing what the desktops get** is a commit here plus a pin bump there:
`OMARCHY_SETUP_REF` in `covert-cloud`'s `provision/user.sh` (rebuild the image)
and in the template's `main.tf` (template push; reaches running desktops at
their next start).
## The MacBook Air M2
Captured as it was on 2026-10-16 (Omarchy 4.0.3rc4), read-only over SSH: no
hand-built themes, no shell plugins, stock bar and terminal fonts, so it
includes `personal` but not `personal-desktop`. Add that to its `include=` to
give it the HP's desktop. What is its own:
- `monitors.lua`: monitor scale 1.33333, GDK scale 1, no named outputs.
- `input.lua`: stock gestures (no three-finger workspace swipe), and the
measured note on why `sensitivity`/`accel_profile` rules are inert for the
lan-mouse virtual pointer.
- `/etc/modprobe.d`: `hid_apple fnmode=1` (the built-in keyboard; the HP uses 2
for an external Apple keyboard) and `appledrm show_notch=1`.
- `lan-mouse.service.d/pointer-scale.conf`: `LAN_MOUSE_POINTER_SCALE=0.7`, for
the Air's 140 logical ppi against the LG's ~96.
- Its own `herdr/config.toml` (no agent-row customization yet), `opencode.json`
(`"theme": "system"`) and `voxtype/config.toml`.
- The Asahi platform packages, listed so `capture.sh` notices drift.
Left out: `~/.config/opencode/opencode.gateway.json` (holds the LiteLLM key;
`dotfiles`' `mcp-config` writes it), `~/.config/lan-mouse/`, and the
post-update hooks and `omarchy-asahi-mic.service`, which Omarchy installs
itself.
## The HP laptop: its customizations, and why
**Displays** (`profiles/machines/hp-laptop/config/hypr/monitors.lua`) are the
fiddliest part. The file is the single source of truth for monitor rules on
purpose — overlapping rules spread across several files is what previously made
external displays unrecoverable. Three outputs are named by EDID description
rather than connector, so they match whichever port they land on: the built-in
Chimei panel at 1.25x, the LG IPS236 on HDMI at 1x, and the LG TV through the
Anker dock. The TV is deliberately left on `preferred` (3840x2160@30) — the dock
negotiates DP alt-mode with only two lanes, so forcing 4K60 produced a "monitor
has no mode" loop. `GDK_SCALE=1` with a catch-all monitor scale of 1 (the
built-in panel and the TV set 1.25 in their own rules); the stock pairing is 2
and `auto`.
`hyprland.lua` sources `hyprmoncfg-monitors.lua` last so the `hyprmoncfg`
daemon's generated layout wins over anything above it.
`profiles/machines/hp-laptop/bin/fix-stuck-displays` recovers the related
kernel-side failure: after suspend/resume i915 can leave an unplugged connector
"disconnected" but still "enabled" on a pipe Hyprland has forgotten, which
blocks every other output from modesetting. It forces the connector on, disables
it through Hyprland to release the pipe, then returns it to auto-detect. It
needs passwordless sudo for `tee /sys/class/drm/*/status`.
**Keyboard/mouse sharing** (`profiles/features/handoff/`: `bin/omarchy-handoff`,
`systemd/user/lan-mouse.service`) drives `lan-mouse` to share input with the
other Omarchy laptop (the Air), bound to `SUPER+SHIFT+K` (`SUPER+K` is already
Keybindings). The script's header comment is the real documentation — it records
the non-obvious facts that cost hours: `capture_backend` must be `layer-shell`
(the portal backend is a silent no-op on Hyprland 0.56.2),
`[authorized_fingerprints]` is keyed by fingerprint and the reversed form fails
silently, and the log variable is `LAN_MOUSE_LOG_LEVEL`, not `RUST_LOG`. It runs
as a user unit rather than from `autostart.lua` because `o.launch_on_start`
sends both streams to `/dev/null`, hiding every pairing failure.
Three further facts were only established after the above was first written, and Three further facts were only established after the above was first written, and
they are what actually made it work: they are what actually made it work:
@@ -72,7 +190,8 @@ they are what actually made it work:
digest is the *correct* digest, valid TOML, non-empty, passes every guard, and digest is the *correct* digest, valid TOML, non-empty, passes every guard, and
never matches: the DTLS handshake dies `bad_certificate`, indistinguishable never matches: the DTLS handshake dies `bad_certificate`, indistinguishable
from a wrong key or a dropped packet. This was the root cause of a full day of from a wrong key or a dropped packet. This was the root cause of a full day of
dead crossings. `omarchy-handoff fixcase` repairs it; `doctor` now fails on it. dead crossings. `omarchy-handoff fixcase` repairs it; `doctor` now fails on
it.
- **Authorization is receiver-side only, not mutual.** `connect.rs` sets - **Authorization is receiver-side only, not mutual.** `connect.rs` sets
`insecure_skip_verify`, so the *connecting* side never checks the peer's `insecure_skip_verify`, so the *connecting* side never checks the peer's
certificate — only the listener consults `authorized_fingerprints`. Each certificate — only the listener consults `authorized_fingerprints`. Each
@@ -80,65 +199,73 @@ they are what actually made it work:
needs both lists, and a sender cannot tell whether it is talking to the real needs both lists, and a sender cannot tell whether it is talking to the real
peer. Input goes to whatever answers on that IP:port. peer. Input goes to whatever answers on that IP:port.
- **`ufw` is default-deny inbound**, and a dropped inbound 4242 presents - **`ufw` is default-deny inbound**, and a dropped inbound 4242 presents
*identically* to a bad certificate. `install.sh --handoff` opens it for the *identically* to a bad certificate. `install.sh handoff` opens it for the
peer's addresses. Check it first: `journalctl -k -g 'UFW BLOCK'`. addresses in the profile's `handoff_peer_ips`. Check it first: `journalctl -k
-g 'UFW BLOCK'`.
`handoff/patch/` carries a two-line source patch so the daemon forwards the `profiles/features/handoff/handoff/patch/` carries a two-line source patch so
accelerated deltas, which is the only way to make the sender's `sensitivity` and the daemon forwards the accelerated deltas, which is the only way to make the
`accel_profile` affect remote pointer feel at all; it also adds sender's `sensitivity` and `accel_profile` affect remote pointer feel at all; it
`LAN_MOUSE_POINTER_SCALE` for the residual difference in logical pixel density also adds `LAN_MOUSE_POINTER_SCALE` for the residual difference in logical pixel
between the two screens. `install.sh` builds it per-machine — the two laptops are density between the two screens. `install.sh` builds it per-machine — the two
different architectures — and `systemd/user/lan-mouse.service` runs the result. laptops are different architectures — and `lan-mouse.service` runs the result.
The Air's `LAN_MOUSE_POINTER_SCALE=0.7` is a systemd drop-in in its own layer
(`lan-mouse.service.d/pointer-scale.conf`), so the shared unit stays at 1.0.
`system/sysctl.d/99-omarchy-handoff-arp.conf` goes with it: Wi-Fi and the dock's `profiles/machines/hp-laptop/system/sysctl.d/99-omarchy-handoff-arp.conf` goes
ethernet both sit on `192.168.1.0/24`, and with the default ARP settings the with it: Wi-Fi and the dock's ethernet both sit on `192.168.1.0/24`, and with
subnet's mapping collapses onto whichever NIC replied last. the default ARP settings the subnet's mapping collapses onto whichever NIC
replied last.
**Bar and shell** (`config/omarchy/shell.json`) adds `omarchy.active-window` to **Bar and shell** (`profiles/personal-desktop/config/omarchy/shell.json`) adds
the left, five plugin widgets, and `indie.herdr-hud` as a non-bar plugin. Idle is `omarchy.active-window` to the left, five plugin widgets, and `indie.herdr-hud`
stretched a long way past stock — screensaver at 20 minutes, lock at 30, against as a non-bar plugin. Idle is stretched a long way past stock — screensaver at 20
2.5 and 5. minutes, lock at 30, against 2.5 and 5.
**Keybindings** (`config/hypr/bindings.lua`) rebinds `SUPER+SHIFT+A` from the **Keybindings** live in two layer files.
stock ChatGPT webapp to Claude, and adds four Herdr HUD bindings plus Handoff. `profiles/personal-desktop/config/hypr/layers/40-personal-desktop.lua` rebinds
`SUPER+SHIFT+A` from the stock ChatGPT webapp to Claude and adds four Herdr HUD
bindings; the handoff feature's `50-handoff.lua` adds Handoff.
**Input** (`config/hypr/input.lua`) enables the three-finger horizontal **Input** (`profiles/machines/hp-laptop/config/hypr/input.lua`) enables the
workspace gesture that ships commented out, and flattens pointer acceleration for three-finger horizontal workspace gesture that ships commented out, and flattens
`unknown-device*`. pointer acceleration for `unknown-device*`.
That second rule is **a no-op, and the reasoning originally given for it was That second rule is **a no-op, and the reasoning originally given for it was
wrong.** It was added believing lan-mouse forwards deltas the sending compositor wrong.** It was added believing lan-mouse forwards deltas the sending compositor
had already accelerated, which the receiver then amplified a second time. Upstream had already accelerated, which the receiver then amplified a second time.
does the opposite: it forwards the *unaccelerated* deltas and discards the Upstream does the opposite: it forwards the *unaccelerated* deltas and discards
accelerated pair in the same event (`handoff/patch/REBUILD.md`). And the rule the accelerated pair in the same event
cannot act either way — `hl.device({name="unknown-device"})` does match the (`profiles/features/handoff/handoff/patch/REBUILD.md`). And the rule cannot act
virtual pointer (provable by toggling `scroll_factor`), but `sensitivity` and either way — `hl.device({name="unknown-device"})` does match the virtual pointer
`accel_profile` are libinput pointer-acceleration settings and a (provable by toggling `scroll_factor`), but `sensitivity` and `accel_profile`
`wlr-virtual-pointer` is not a libinput device, so they have nothing to act on. are libinput pointer-acceleration settings and a `wlr-virtual-pointer` is not a
Measured 2026-10-10 on both machines with `lan-mouse test-emulation` + `hyprctl libinput device, so they have nothing to act on. Measured 2026-10-10 on both
cursorpos`: travel was 398x198px at `sensitivity = 0` and identical at `-0.9`, machines with `lan-mouse test-emulation` + `hyprctl cursorpos`: travel was
exactly 1:1 with the raw deltas. The lines are kept because they are harmless and 398x198px at `sensitivity = 0` and identical at `-0.9`, exactly 1:1 with the raw
the `unknown-device` naming note is worth preserving; the real fix is deltas. The lines are kept because they are harmless and the `unknown-device`
`handoff/patch/`. Do not add sensitivity or accel_profile rules here expecting naming note is worth preserving; the real fix is `handoff/patch/`. Do not add
them to change remote pointer feel. sensitivity or accel_profile rules here expecting them to change remote pointer
feel.
**Terminals** are all dropped to a smaller font (9 -> 8) except ghostty, which **Terminals** are all dropped to a smaller font (9 -> 8) except ghostty, which
went the other way to 12. `shell.toml` sets the bar font base size to 10. went the other way to 12. `shell.toml` sets the bar font base size to 10.
**Branding** (`config/omarchy/branding/`) replaces the about and screensaver **Branding** (`profiles/personal/config/omarchy/branding/`, shared with the Air)
ASCII art. replaces the about and screensaver ASCII art.
**`hid_apple` fnmode=2** makes the F-keys act as F-keys on an Apple keyboard. **`hid_apple` fnmode=2** makes the F-keys act as F-keys on an Apple keyboard.
It only takes effect after `sudo mkinitcpio -P` or a module reload. It only takes effect after `sudo mkinitcpio -P` or a module reload.
## Local plugin work ## Local plugin work
`indie.herdr-hud` is the one piece that exists on no remote. Ten commits live on `indie.herdr-hud` is the one piece that exists on no remote. Its `local` branch
its `local` branch — touchpad scroll feel, agent ordering, context-size display — carries commits — touchpad scroll feel, agent ordering, context-size display —
and they stay off the upstream PR deliberately. `plugin-patches/` carries them as and they stay off the upstream PR deliberately.
a git bundle plus a patch for the uncommitted working tree, and `install.sh` `profiles/personal-desktop/plugin-patches/` carries them as a git bundle plus a
replays both after cloning. `io.github.tallsam.navbar-cat` has a one-file working patch for the uncommitted working tree, and `install.sh` replays both after
tree change captured the same way. cloning. Two untracked scripts in the same plugin, `bin/herdr-agents` and
`bin/herdr-web`, ride along as `indie.herdr-hud--bin--<name>`.
`capture.sh plugins` re-bundles and re-diffs these, so local work keeps flowing `capture.sh plugins` re-bundles and re-diffs these, so local work keeps flowing
back into the repo. back into the repo.
@@ -148,42 +275,62 @@ back into the repo.
- **Secrets.** `~/.config/lan-mouse/` holds a private key and per-machine - **Secrets.** `~/.config/lan-mouse/` holds a private key and per-machine
fingerprints; `~/.config/novnc/auth.env`, SSH keys and the git credential fingerprints; `~/.config/novnc/auth.env`, SSH keys and the git credential
helpers are likewise machine-local. Start from helpers are likewise machine-local. Start from
`handoff/config/config.toml.example-{air,laptop}` (which carry the `profiles/features/handoff/handoff/config/config.toml.example-{air,laptop}`
`capture_backend`, edge and lowercase-fingerprint rules), then pair with (which carry the `capture_backend`, edge and lowercase-fingerprint rules),
`omarchy-handoff pair <peer> <lowercase-sha256>` and `omarchy-handoff doctor`. then pair with `omarchy-handoff pair <peer> <lowercase-sha256>` and
A fresh install regenerates the certificate, so this machine's fingerprint `omarchy-handoff doctor`. A fresh install regenerates the certificate, so this
changes and the *peer* has to re-pair. Back up `lan-mouse.pem` beforehand if machine's fingerprint changes and the *peer* has to re-pair. Back up
you would rather keep it stable — somewhere private, not here. `lan-mouse.pem` beforehand if you would rather keep it stable — somewhere
private, not here.
- **Shell, git and dev tooling.** That is already its own repo, `dotfiles` - **Shell, git and dev tooling.** That is already its own repo, `dotfiles`
(`git.nic-oconnor.com/public/dotfiles`), which handles zsh, mise, 1Password and (`git.nic-oconnor.com/public/dotfiles`), which handles zsh, mise, 1Password
the `~/.local/bin` tool shims. The only overlap avoided here is and the `~/.local/bin` tool shims. The only overlap avoided here is
`~/.config/git/config`, whose customizations are identity and credential `~/.config/git/config`, whose customizations are identity and credential
helpers — `dotfiles` territory. helpers — `dotfiles` territory.
- **The remote-access stack.** `wayvnc`, `novnc`, `sunshine`, `freerdp`, `remmina` - **The remote-access stack.** `wayvnc`, `novnc`, `sunshine`, `freerdp`,
and `omarchy-remote-desktop` are installed on this laptop but scoped out. `remmina` and `omarchy-remote-desktop` are installed on this laptop but scoped
`kokd.remote-desktop` is still in `plugins.txt` since it is a bar plugin, but out. `kokd.remote-desktop` is still in `plugins.txt` since it is a bar plugin,
it is not placed in `shell.json`. but it is not placed in `shell.json`.
- **Work tooling.** The keycloak helpers (`kcadm`, `kclogin`, `wren-keycloak`) and - **Two plugins with no remote yet.** `niccoconnor.mantel` and
`paperclipai.service`. `niccoconnor.keyhints` are installed on the HP and placed in `shell.json`, but
neither has an `origin`, so `plugins.txt` cannot pin them (`capture.sh` says
so). Until they are pushed, another machine gets a bar with those two slots
empty.
- **Work tooling.** The keycloak helpers (`kcadm`, `kclogin`, `wren-keycloak`)
and `paperclipai.service`.
- **aether-managed themes.** `pink-gt3rs`, `porsche-cayman` and `porsche-rain` - **aether-managed themes.** `pink-gt3rs`, `porsche-cayman` and `porsche-rain`
carry a `.aether-managed` marker and are reinstallable with `aether`, so their carry a `.aether-managed` marker and are reinstallable with `aether`, so their
~18MB of wallpapers and previews stay out. ~18MB of wallpapers and previews stay out.
- **`btop.conf`.** Its only diff against stock is v1.4.7 default drift, not a - **`btop.conf`.** Its only diff against stock is v1.4.7 default drift, not a
real customization. real customization.
- **Backup files.** Fifteen `.bak.*` and `.prefix.*` files are sitting in - **Backup files.** Fifteen `.bak.*` and `.prefix.*` files are sitting in
`~/.config/hypr` and `~/.config/omarchy` from earlier editing sessions. They are `~/.config/hypr` and `~/.config/omarchy` from earlier editing sessions. They
not tracked and are safe to delete. are not tracked and are safe to delete.
## Adding a file ## Adding things
`capture.sh` only refreshes what the repo already tracks, so start it by hand: **A file.** `capture.sh` only refreshes what the repo already tracks, so start
it by hand, in the layer it belongs to:
```bash ```bash
cp ~/.config/foo/bar.conf config/foo/bar.conf # path mirrors ~/.config cp ~/.config/foo/bar.conf profiles/machines/hp-laptop/config/foo/bar.conf
git add config/foo/bar.conf git add profiles/machines/hp-laptop/config/foo/bar.conf
``` ```
`install.sh config` and `capture.sh config` both walk `config/` recursively, so Check first that it actually differs from stock —
nothing else needs changing. Check first that the file actually differs from `diff /usr/share/omarchy/config/foo/bar.conf ~/.config/foo/bar.conf` — or you
stock — `diff /usr/share/omarchy/config/foo/bar.conf ~/.config/foo/bar.conf` — will be tracking a file Omarchy already ships. A Hyprland setting goes in that
or you will be tracking a file Omarchy already ships. layer's `config/hypr/layers/NN-<layer>.lua`, not in `bindings.lua` or
`looknfeel.lua`.
**A role.** `mkdir profiles/roles/<name>`, give it a `profile` (`description=`,
optionally `dotfiles=(...)`) and a `packages.txt`, plus
`config/hypr/layers/2N-<name>.lua` if it needs bindings. Use `cmd=` for any
package `dotfiles` might already provide, and an arch for anything Arch Linux
ARM lacks. To offer it on Coder desktops, add it to the template's Roles
parameter in `covert-cloud`.
**A machine.** `mkdir profiles/machines/<name>` with a `profile` naming
`hostnames=`, `arch=` and `include=(base ...)`, then its `monitors.lua`,
`input.lua`, `packages.txt` and `/etc` drop-ins. Run `./install.sh --profile
<name> --dry-run` on it, then `./capture.sh` after any hand-tuning.
+164 -107
View File
@@ -2,11 +2,14 @@
# Pull the live machine's state back into this repo - the reverse of install.sh. # Pull the live machine's state back into this repo - the reverse of install.sh.
# #
# Run it after customizing something by hand, then review `git diff` and commit. # Run it after customizing something by hand, then review `git diff` and commit.
# It only refreshes paths the repo already tracks; it never invents new ones, so # Each live file goes back into the layer that OWNS it for this machine's
# adding a newly customized file is a deliberate act (see README, "Adding a file"). # profile (the last layer providing that path; `./install.sh --show` lists
# them). It only refreshes paths the repo already tracks, so adding a newly
# customized file is a deliberate act (see README, "Adding a file").
# #
# ./capture.sh # refresh everything # ./capture.sh # this machine's profile, every step
# ./capture.sh config # only the named steps # ./capture.sh --profile hp-laptop # name it explicitly
# ./capture.sh config # only the named steps
# ./capture.sh --list # ./capture.sh --list
# #
# Steps: config themes plugins bin services system packages # Steps: config themes plugins bin services system packages
@@ -16,15 +19,19 @@ set -uo pipefail
REPO="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" REPO="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
STEPS=(config themes plugins bin services system packages) STEPS=(config themes plugins bin services system packages)
c_ch=$'\033[33m'; c_skip=$'\033[2m'; c_warn=$'\033[33m'; c_hd=$'\033[1m'; c_off=$'\033[0m' c_ch=$'\033[33m'; c_skip=$'\033[2m'; c_warn=$'\033[33m'; c_err=$'\033[31m'; c_hd=$'\033[1m'; c_off=$'\033[0m'
[[ -t 1 ]] || { c_ch=; c_skip=; c_warn=; c_hd=; c_off=; } [[ -t 1 ]] || { c_ch=; c_skip=; c_warn=; c_err=; c_hd=; c_off=; }
say() { printf '%s\n' "$*"; } say() { printf '%s\n' "$*"; }
head_() { printf '\n%s==> %s%s\n' "$c_hd" "$*" "$c_off"; } head_() { printf '\n%s==> %s%s\n' "$c_hd" "$*" "$c_off"; }
chg() { printf ' %sM %s%s\n' "$c_ch" "$*" "$c_off"; } chg() { printf ' %sM %s%s\n' "$c_ch" "$*" "$c_off"; }
skip() { printf ' %s= %s%s\n' "$c_skip" "$*" "$c_off"; } skip() { printf ' %s= %s%s\n' "$c_skip" "$*" "$c_off"; }
warn() { printf ' %s! %s%s\n' "$c_warn" "$*" "$c_off"; } warn() { printf ' %s! %s%s\n' "$c_warn" "$*" "$c_off"; }
err() { printf ' %sx %s%s\n' "$c_err" "$*" "$c_off"; }
gone() { printf ' %s? %s%s\n' "$c_warn" "$*" "$c_off"; } gone() { printf ' %s? %s%s\n' "$c_warn" "$*" "$c_off"; }
# shellcheck source=lib/layers.sh
source "$REPO/lib/layers.sh"
# pull_back <live> <repo-copy> <label> # pull_back <live> <repo-copy> <label>
pull_back() { pull_back() {
local live=$1 dest=$2 label=$3 local live=$1 dest=$2 label=$3
@@ -32,121 +39,139 @@ pull_back() {
if cmp -s "$live" "$dest"; then skip "$label"; else cp "$live" "$dest"; chg "$label"; fi if cmp -s "$live" "$dest"; then skip "$label"; else cp "$live" "$dest"; chg "$label"; fi
} }
step_config() { # pull_tree <subdir> <live-root> -- every owned file under <layer>/<subdir>
head_ "~/.config -> config/" pull_tree() {
local dest rel local sub=$1 root=$2 rel layer
while IFS= read -r -d '' dest; do while IFS=$'\t' read -r rel layer; do
rel=${dest#"$REPO/config/"} pull_back "$root/$rel" "$PROFILES/$layer/$sub/$rel" "$rel [$layer]"
pull_back "$HOME/.config/$rel" "$dest" "$rel" done < <(owners "$sub")
done < <(find "$REPO/config" -type f -print0 | sort -z)
} }
step_config() { head_ "~/.config -> layers' config/"; pull_tree config "$HOME/.config"; }
step_bin() { head_ "~/.local/bin -> layers' bin/"; pull_tree bin "$HOME/.local/bin"; }
step_services() { head_ "systemd user units -> layers' systemd/"; pull_tree systemd/user "$HOME/.config/systemd/user"; }
step_system() { head_ "/etc drop-ins -> layers' system/"; pull_tree system /etc; }
step_themes() { step_themes() {
head_ "Themes" head_ "Themes"
local t name live local l t name live
for t in "$REPO"/themes/*/; do declare -A theme=()
[[ -d $t ]] || continue for l in "${LAYERS[@]}"; do
name=$(basename -- "$t") for t in "$PROFILES/$l"/themes/*/; do [[ -d $t ]] && theme[$(basename -- "$t")]=$l; done
done
for name in $(printf '%s\n' "${!theme[@]}" | sort); do
t="$PROFILES/${theme[$name]}/themes/$name/"
live="$HOME/.config/omarchy/themes/$name" live="$HOME/.config/omarchy/themes/$name"
if [[ ! -d $live ]]; then gone "$name - no longer installed"; continue; fi if [[ ! -d $live ]]; then gone "$name - no longer installed"; continue; fi
if diff -rq "$live" "$t" >/dev/null 2>&1; then if diff -rq "$live" "$t" >/dev/null 2>&1; then
skip "$name" skip "$name [${theme[$name]}]"
else else
rm -rf "$t"; mkdir -p "$t"; cp -r "$live/." "$t/" rm -rf "$t"; mkdir -p "$t"; cp -r "$live/." "$t/"
chg "$name" chg "$name [${theme[$name]}]"
fi fi
done done
# Flag themes installed by hand that the repo does not carry. # Flag themes installed by hand that no layer carries.
local l lname local lname
for l in "$HOME"/.config/omarchy/themes/*/; do for l in "$HOME"/.config/omarchy/themes/*/; do
[[ -d $l ]] || continue [[ -d $l ]] || continue
lname=$(basename -- "$l") lname=$(basename -- "$l")
[[ -d "$REPO/themes/$lname" ]] && continue [[ -n ${theme[$lname]:-} ]] && continue
[[ -e "$l/.aether-managed" ]] && continue [[ -e "$l/.aether-managed" ]] && continue
warn "$lname is installed but not in this repo (cp -r it into themes/ to track it)" [[ " ${P_AETHER[*]} " == *" $lname "* ]] && continue
warn "$lname is installed but in no layer (cp -r it into a layer's themes/ to track it)"
done done
} }
# Re-pin every plugin a layer lists to its live sha, drop the ones that are gone,
# and add live plugins no layer lists to the last layer that has a plugins.txt.
step_plugins() { step_plugins() {
head_ "Plugin pins and local work" head_ "Plugin pins and local work"
local dir="$HOME/.config/omarchy/plugins" local dir="$HOME/.config/omarchy/plugins" l f
local tmp; tmp=$(mktemp) local files=()
{ while IFS= read -r l; do files+=("$l"); done < <(layers_with plugins.txt)
echo "# Omarchy shell plugins. Fields: id<TAB>repo<TAB>pinned-sha" if (( ! ${#files[@]} )); then skip "no layer carries plugins"; return 0; fi
echo "# install.sh clones each into ~/.config/omarchy/plugins/<id> and checks out the sha."
local p id url sha
for p in "$dir"/*/; do
[[ -d $p/.git ]] || continue
id=$(basename -- "$p")
url=$(git -C "$p" remote get-url origin 2>/dev/null) || continue
sha=$(git -C "$p" rev-parse HEAD)
printf '%s\t%s\t%s\n' "$id" "$url" "$sha"
done
} > "$tmp"
if cmp -s "$tmp" "$REPO/plugins.txt"; then skip "plugins.txt"; else mv "$tmp" "$REPO/plugins.txt"; chg "plugins.txt"; fi
rm -f "$tmp"
# herdr-hud's local-only branch, re-bundled declare -A listed=()
local p="$dir/indie.herdr-hud" local id u s line tmp
if [[ -d $p/.git ]] && git -C "$p" rev-parse --verify --quiet local >/dev/null; then for l in "${files[@]}"; do
local b="$REPO/plugin-patches/indie.herdr-hud.bundle" f="$PROFILES/$l/plugins.txt"; tmp=$(mktemp)
git -C "$p" bundle create "$b.new" origin/main..local >/dev/null 2>&1 while IFS= read -r line; do
# Bundles embed a timestamp-free pack but differ byte-wise; compare the tips instead. if [[ -z $line || $line == \#* ]]; then printf '%s\n' "$line" >> "$tmp"; continue; fi
if [[ -f $b ]] && [[ $(git -C "$p" bundle list-heads "$b" 2>/dev/null) == $(git -C "$p" bundle list-heads "$b.new" 2>/dev/null) ]]; then IFS=$'\t' read -r id u s <<<"$line"
rm -f "$b.new"; skip "indie.herdr-hud.bundle" listed[$id]=1
else if [[ ! -d $dir/$id/.git ]]; then
mv "$b.new" "$b"; chg "indie.herdr-hud.bundle" gone "$id - no longer installed, dropped from $l/plugins.txt"
continue
fi
printf '%s\t%s\t%s\n' "$id" "$u" "$(git -C "$dir/$id" rev-parse HEAD)" >> "$tmp"
done < "$f"
if [[ $l == "${files[-1]}" ]]; then
local p
for p in "$dir"/*/; do
id=$(basename -- "$p")
[[ -n ${listed[$id]:-} ]] && continue
if [[ ! -d $p/.git ]]; then warn "$id is not a git checkout - not tracked"; continue; fi
if ! u=$(git -C "$p" remote get-url origin 2>/dev/null); then
warn "$id has no origin remote - push it somewhere before it can be pinned"; continue
fi
printf '%s\t%s\t%s\n' "$id" "$u" "$(git -C "$p" rev-parse HEAD)" >> "$tmp"
chg "$id - new, added to $l/plugins.txt"
done
fi fi
[[ -f $p/bin/herdr-agents ]] && pull_back "$p/bin/herdr-agents" \ if cmp -s "$tmp" "$f"; then skip "$l/plugins.txt"; else mv "$tmp" "$f"; chg "$l/plugins.txt"; fi
"$REPO/plugin-patches/indie.herdr-hud--bin--herdr-agents" "indie.herdr-hud bin/herdr-agents" rm -f "$tmp"
fi done
local f id # Local plugin work, per layer: re-bundle 'local' branches, re-diff working
for f in "$REPO"/plugin-patches/*-worktree.patch; do # trees, pull back --bin-- files. See install.sh step_plugins.
[[ -f $f ]] || continue local b new p untracked
id=$(basename -- "$f"); id=${id%-worktree.patch} for l in "${LAYERS[@]}"; do
if [[ ! -d $dir/$id/.git ]]; then gone "$id - not installed"; continue; fi [[ -d $PROFILES/$l/plugin-patches ]] || continue
local tmp2; tmp2=$(mktemp) for b in "$PROFILES/$l"/plugin-patches/*.bundle; do
git -C "$dir/$id" diff > "$tmp2" [[ -f $b ]] || continue
if cmp -s "$tmp2" "$f"; then skip "$id-worktree.patch"; else mv "$tmp2" "$f"; chg "$id-worktree.patch"; fi id=$(basename -- "$b" .bundle); p="$dir/$id"
rm -f "$tmp2" [[ -d $p/.git ]] && git -C "$p" rev-parse --verify --quiet local >/dev/null || { gone "$id: no 'local' branch here"; continue; }
new="$b.new"
git -C "$p" bundle create "$new" origin/main..local >/dev/null 2>&1
# Bundles differ byte-wise run to run; compare the tips instead.
if [[ $(git -C "$p" bundle list-heads "$b" 2>/dev/null) == $(git -C "$p" bundle list-heads "$new" 2>/dev/null) ]]; then
rm -f "$new"; skip "$id.bundle [$l]"
else
mv "$new" "$b"; chg "$id.bundle [$l]"
fi
done
for f in "$PROFILES/$l"/plugin-patches/*-worktree.patch; do
[[ -f $f ]] || continue
id=$(basename -- "$f"); id=${id%-worktree.patch}; p="$dir/$id"
if [[ ! -d $p/.git ]]; then gone "$id - not installed"; continue; fi
tmp=$(mktemp)
git -C "$p" diff > "$tmp"
if cmp -s "$tmp" "$f"; then skip "$id-worktree.patch [$l]"; else mv "$tmp" "$f"; chg "$id-worktree.patch [$l]"; fi
rm -f "$tmp"
# Untracked files are not in a diff; only --bin-- files carry them.
while IFS= read -r untracked; do
[[ $untracked == bin/* && -f $PROFILES/$l/plugin-patches/$id--bin--${untracked#bin/} ]] && continue
warn "$id: untracked $untracked is not captured (bin/ files: cp to plugin-patches/$id--bin--<name>)"
done < <(git -C "$p" ls-files --others --exclude-standard)
done
for f in "$PROFILES/$l"/plugin-patches/*--bin--*; do
[[ -f $f ]] || continue
id=$(basename -- "$f")
pull_back "$dir/${id%%--bin--*}/bin/${id##*--bin--}" "$f" "${id%%--bin--*} bin/${id##*--bin--} [$l]"
done
done done
} }
step_bin() { # Report drift in the package delta without rewriting the hand-annotated files:
head_ "~/.local/bin -> bin/" # each line carries a "why" comment that a regenerated list would lose.
local f
for f in "$REPO"/bin/*; do
[[ -f $f ]] || continue
pull_back "$HOME/.local/bin/$(basename -- "$f")" "$f" "$(basename -- "$f")"
done
}
step_services() {
head_ "systemd user units"
local f name
for f in "$REPO"/systemd/user/*.service; do
[[ -f $f ]] || continue
name=$(basename -- "$f")
pull_back "$HOME/.config/systemd/user/$name" "$f" "$name"
done
}
step_system() {
head_ "/etc drop-ins"
local dest rel
while IFS= read -r -d '' dest; do
rel=${dest#"$REPO/system/"}
pull_back "/etc/$rel" "$dest" "/etc/$rel"
done < <(find "$REPO/system" -type f -print0 | sort -z)
}
# Report drift in the package delta without rewriting the hand-annotated file:
# packages.txt carries a "why" comment per line that a regenerated list would lose.
step_packages() { step_packages() {
head_ "Package delta (report only)" head_ "Package delta (report only)"
load_packages
local listed; listed=$(mktemp) local listed; listed=$(mktemp)
sed 's/#.*//' "$REPO/packages.txt" | tr -d '[:blank:]' | grep -v '^$' | grep -v '^\[' | sort -u > "$listed" local e name archs cmds layer
for e in "${PKG_repo[@]}" "${PKG_aur[@]}" "${PKG_optional[@]}" "${PKG_ignore[@]}"; do
IFS=$PKG_FS read -r name archs cmds layer <<<"$e"; printf '%s\n' "$name"
done | sort -u > "$listed"
local baseline; baseline=$(mktemp) local baseline; baseline=$(mktemp)
{ {
@@ -155,32 +180,64 @@ step_packages() {
cat /usr/share/omarchy/install/omarchy-base.packages 2>/dev/null cat /usr/share/omarchy/install/omarchy-base.packages 2>/dev/null
} | grep -v '^$' | sort -u > "$baseline" } | grep -v '^$' | sort -u > "$baseline"
local extra; extra=$(mktemp) local untracked; untracked=$(pacman -Qqe 2>/dev/null | sort -u | comm -23 - "$baseline" | comm -23 - "$listed" | tr '\n' ' ')
pacman -Qqe 2>/dev/null | sort -u | comm -23 - "$baseline" > "$extra" local missing=()
for e in "${PKG_repo[@]}" "${PKG_aur[@]}"; do
IFS=$PKG_FS read -r name archs cmds layer <<<"$e"
pkg_applies "$archs" || continue
pacman -Qq "$name" &>/dev/null || pkg_cmd_present "$cmds" || missing+=("$name [$layer]")
done
local untracked; untracked=$(comm -23 "$extra" "$listed" | grep -vxE 'base|base-devel|sudo|omarchy|mkinitcpio|linux-firmware|efibootmgr|devtools|cmake|ninja|wayvnc|sunshine|freerdp|remmina|omarchy-remote-desktop-backend|pipewire-debug' | tr '\n' ' ') if [[ -n ${untracked// } ]]; then
local removed; removed=$(comm -13 "$extra" "$listed" | tr '\n' ' ') warn "installed but in no layer's packages.txt: $untracked"
say " add each to the layer it belongs to, or to $PROFILE/packages.txt [ignore]"
[[ -n ${untracked// } ]] && warn "installed but not in packages.txt: $untracked" || skip "nothing installed that packages.txt misses" else
[[ -n ${removed// } ]] && warn "in packages.txt but not installed: $removed" skip "nothing installed that the layers miss"
fi
(( ${#missing[@]} )) && warn "listed but not installed: ${missing[*]}"
say " packages.txt keeps a 'why' comment per package, so edit it by hand." say " packages.txt keeps a 'why' comment per package, so edit it by hand."
rm -f "$listed" "$baseline" "$extra" rm -f "$listed" "$baseline"
} }
main() { main() {
local want=() local want=() profile="" force=0 a
for a in "$@"; do while (( $# )); do
a=$1; shift
case $a in case $a in
--list|-l) printf '%s\n' "${STEPS[@]}"; exit 0 ;; --profile|-p) profile=${1:?--profile needs a name}; shift ;;
-h|--help) sed -n '2,13p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; --profile=*) profile=${a#*=} ;;
-*) warn "unknown option: $a"; exit 2 ;; --force) force=1 ;;
--list|-l) printf '%s\n' "${STEPS[@]}"; exit 0 ;;
-h|--help) awk 'NR > 1 && /^#/ { sub(/^# ?/, ""); print; next } NR > 1 { exit }' "${BASH_SOURCE[0]}"; exit 0 ;;
-*) warn "unknown option: $a"; exit 2 ;;
*) if [[ " ${STEPS[*]} " == *" $a "* ]]; then want+=("$a"); else warn "unknown step: $a"; exit 2; fi ;; *) if [[ " ${STEPS[*]} " == *" $a "* ]]; then want+=("$a"); else warn "unknown step: $a"; exit 2; fi ;;
esac esac
done done
(( ${#want[@]} )) || want=("${STEPS[@]}") (( ${#want[@]} )) || want=("${STEPS[@]}")
say "${c_hd}capture${c_off} repo=$REPO host=$(hostname)" local saved="" roles=""
for s in "${want[@]}"; do "step_$s"; done if def=$(default_profile); then IFS=$'\t' read -r saved roles <<<"$def"; fi
[[ -n $profile ]] || profile=$saved
[[ -n $profile ]] || { err "no --profile given and none matches host $(hostname)"; exit 2; }
local rp; rp=$(find_layer "$profile") || { err "no such profile: $profile"; exit 2; }
[[ $rp == "$saved" ]] || roles=""
ROLES=(); [[ -n $roles ]] && IFS=', ' read -ra ROLES <<<"$roles"
# Before the first layered install, the live files still have the old shape
# (e.g. bindings in bindings.lua rather than hypr/layers/), and capturing them
# would write machine-specific content into shared layers like base/.
if [[ ! -f $STATE_FILE ]] && (( ! force )); then
err "no layered install recorded on this machine ($STATE_FILE)."
say " Run ./install.sh first, or --force and review the diff of shared layers by hand."
exit 2
fi
resolve "$rp" "${ROLES[@]}" || exit 2
load_settings
say "${c_hd}capture${c_off} profile=$PROFILE${ROLES:+ roles=${ROLES[*]}} host=$(hostname)"
# Canonical order whatever order they were named in: setup after config, etc.
for s in "${STEPS[@]}"; do [[ " ${want[*]} " == *" $s "* ]] && "step_$s"; done
head_ "Done" head_ "Done"
say " Review with: git -C $REPO diff" say " Review with: git -C $REPO diff"
+353 -162
View File
@@ -1,28 +1,38 @@
#!/bin/bash #!/bin/bash
# Apply this repo's Omarchy customizations to the machine it runs on. # Apply an omarchy-setup profile to the machine it runs on.
# #
# Safe to re-run: every step is a no-op when the target already matches, and any # Safe to re-run: every step is a no-op when the target already matches, and any
# file it would overwrite is backed up to <file>.bak.<epoch> first. # file it would overwrite is backed up to <file>.bak.<epoch> first.
# #
# ./install.sh # everything # ./install.sh # this machine's profile, every step
# ./install.sh config themes # only the named steps # ./install.sh --profile hp-laptop # pick the profile
# ./install.sh --list # show the steps # ./install.sh --roles devops,ai-agents # add roles on top of the profile
# ./install.sh --dry-run # print what would change, touch nothing # ./install.sh config themes # only the named steps
# ./install.sh --dry-run # print what would change, touch nothing
# ./install.sh --no-clobber # never overwrite a file that differs
# ./install.sh --show # the layers, and which layer owns each file
# ./install.sh --profiles | --list # list profiles | steps
# #
# Steps: packages config themes plugins bin services system # Exits non-zero if any step reported an error (x).
#
# With no --profile it uses the one the last install recorded in
# ~/.config/omarchy-setup/profile, else the machine whose hostnames= matches.
#
# Steps: packages config themes plugins bin handoff services system setup
set -uo pipefail set -uo pipefail
REPO="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" REPO="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# Omarchy's commands (omarchy-theme-set, omarchy-pkg-add) are only on PATH in a
# login session; an image build over SSH or a Coder start script has neither.
OMARCHY_PATH=${OMARCHY_PATH:-/usr/share/omarchy}
export OMARCHY_PATH
case ":$PATH:" in *":$OMARCHY_PATH/bin:"*) ;; *) [[ -d $OMARCHY_PATH/bin ]] && PATH="$PATH:$OMARCHY_PATH/bin" ;; esac
STAMP="$(date +%s)" STAMP="$(date +%s)"
DRY=0 DRY=0
STEPS=(packages config themes plugins bin handoff services system) NO_CLOBBER=0
FORCE=0
# omarchy-handoff peer addresses to open the firewall for. The peer is dual-homed STEPS=(packages config themes plugins bin handoff services system setup)
# (Wi-Fi + dock NIC) and the dock address disappears on undock, so list both.
if [[ -z ${HANDOFF_PEER_IPS+x} ]]; then
HANDOFF_PEER_IPS=(192.168.1.24 192.168.1.86)
fi
c_ok=$'\033[32m'; c_skip=$'\033[2m'; c_warn=$'\033[33m'; c_err=$'\033[31m'; c_hd=$'\033[1m'; c_off=$'\033[0m' c_ok=$'\033[32m'; c_skip=$'\033[2m'; c_warn=$'\033[33m'; c_err=$'\033[31m'; c_hd=$'\033[1m'; c_off=$'\033[0m'
[[ -t 1 ]] || { c_ok=; c_skip=; c_warn=; c_err=; c_hd=; c_off=; } [[ -t 1 ]] || { c_ok=; c_skip=; c_warn=; c_err=; c_hd=; c_off=; }
@@ -32,10 +42,14 @@ head_() { printf '\n%s==> %s%s\n' "$c_hd" "$*" "$c_off"; }
ok() { printf ' %s+%s %s\n' "$c_ok" "$c_off" "$*"; } ok() { printf ' %s+%s %s\n' "$c_ok" "$c_off" "$*"; }
skip() { printf ' %s= %s%s\n' "$c_skip" "$*" "$c_off"; } skip() { printf ' %s= %s%s\n' "$c_skip" "$*" "$c_off"; }
warn() { printf ' %s! %s%s\n' "$c_warn" "$*" "$c_off"; } warn() { printf ' %s! %s%s\n' "$c_warn" "$*" "$c_off"; }
err() { printf ' %sx %s%s\n' "$c_err" "$*" "$c_off"; } ERRORS=0
err() { printf ' %sx %s%s\n' "$c_err" "$*" "$c_off"; ERRORS=$((ERRORS + 1)); }
run() { if (( DRY )); then printf ' %s? would: %s%s\n' "$c_skip" "$*" "$c_off"; else "$@"; fi; } run() { if (( DRY )); then printf ' %s? would: %s%s\n' "$c_skip" "$*" "$c_off"; else "$@"; fi; }
# shellcheck source=lib/layers.sh
source "$REPO/lib/layers.sh"
# install_file <src> <dest> [mode] -- copy only when content differs, backing up first # install_file <src> <dest> [mode] -- copy only when content differs, backing up first
install_file() { install_file() {
local src=$1 dest=$2 mode=${3:-} local src=$1 dest=$2 mode=${3:-}
@@ -44,6 +58,10 @@ install_file() {
skip "$rel" skip "$rel"
return 0 return 0
fi fi
if [[ -e $dest ]] && (( NO_CLOBBER )); then
warn "$rel differs from the profile - kept (--no-clobber)"
return 0
fi
if (( DRY )); then if (( DRY )); then
printf ' %s? would write %s%s\n' "$c_skip" "$rel" "$c_off" printf ' %s? would write %s%s\n' "$c_skip" "$rel" "$c_off"
return 0 return 0
@@ -61,77 +79,110 @@ install_file() {
fi fi
} }
# hypr_reload -- reload Hyprland if one is running, even from outside the
# session (a Coder workspace-start script has no HYPRLAND_INSTANCE_SIGNATURE).
hypr_reload() {
(( DRY )) && return 0
command -v hyprctl >/dev/null || return 0
local sig=${HYPRLAND_INSTANCE_SIGNATURE:-}
if [[ -z $sig ]]; then
sig=$(hyprctl instances -j 2>/dev/null | jq -r '.[0].instance // empty' 2>/dev/null)
fi
[[ -n $sig ]] || return 0
HYPRLAND_INSTANCE_SIGNATURE=$sig hyprctl reload >/dev/null 2>&1
local errs; errs=$(HYPRLAND_INSTANCE_SIGNATURE=$sig hyprctl configerrors 2>/dev/null)
if [[ -n $errs && $errs != "no errors"* ]]; then
err "Hyprland reported config errors:"; printf '%s\n' "$errs" | sed 's/^/ /'
else
ok "hyprctl reload - no config errors"
fi
}
# ---------------------------------------------------------------- packages # ---------------------------------------------------------------- packages
step_packages() { step_packages() {
head_ "Packages" head_ "Packages"
command -v omarchy >/dev/null || { err "omarchy not on PATH - is this an Omarchy system?"; return 1; } command -v omarchy >/dev/null || { err "omarchy not on PATH - is this an Omarchy system?"; return 1; }
load_packages
local section="" repo=() aur=() optional=() local e name archs cmds layer missing=() present=0 bycmd=() offarch=0
while IFS= read -r line; do for e in "${PKG_repo[@]}"; do
line=${line%%#*}; line=${line//[[:space:]]/} IFS=$PKG_FS read -r name archs cmds layer <<<"$e"
[[ -z $line ]] && continue if ! pkg_applies "$archs"; then (( offarch++ )); continue; fi
case $line in if pacman -Qq "$name" &>/dev/null; then (( present++ ))
'[repo]') section=repo; continue ;; elif pkg_cmd_present "$cmds"; then bycmd+=("$name")
'[aur]') section=aur; continue ;; else missing+=("$name"); fi
'[optional]') section=optional; continue ;; done
esac
case $section in
repo) repo+=("$line") ;;
aur) aur+=("$line") ;;
optional) optional+=("$line") ;;
esac
done < "$REPO/packages.txt"
local missing=()
for p in "${repo[@]}"; do pacman -Qq "$p" &>/dev/null || missing+=("$p"); done
if (( ${#missing[@]} )); then if (( ${#missing[@]} )); then
say " installing from repos: ${missing[*]}" say " installing from repos: ${missing[*]}"
run omarchy pkg add "${missing[@]}" || err "some repo packages failed" run omarchy pkg add "${missing[@]}" || err "some repo packages failed"
else else
skip "all ${#repo[@]} repo packages present" skip "all $present repo packages present"
fi fi
(( ${#bycmd[@]} )) && skip "already on PATH from elsewhere, not installed: ${bycmd[*]}"
(( offarch )) && skip "$offarch not built for $(uname -m), skipped"
missing=() missing=()
for p in "${aur[@]}"; do pacman -Qq "$p" &>/dev/null || missing+=("$p"); done for e in "${PKG_aur[@]}"; do
IFS=$PKG_FS read -r name archs cmds layer <<<"$e"
pkg_applies "$archs" || continue
pacman -Qq "$name" &>/dev/null || pkg_cmd_present "$cmds" || missing+=("$name")
done
if (( ${#missing[@]} )); then if (( ${#missing[@]} )); then
say " installing from AUR: ${missing[*]}" say " installing from AUR: ${missing[*]}"
run omarchy pkg aur add "${missing[@]}" || err "some AUR packages failed" run omarchy pkg aur add "${missing[@]}" || err "some AUR packages failed"
else else
skip "all ${#aur[@]} AUR packages present" skip "all ${#PKG_aur[@]} AUR packages present"
fi fi
for p in "${optional[@]}"; do for e in "${PKG_optional[@]}"; do
pacman -Qq "$p" &>/dev/null || warn "optional, not installed: $p (omarchy pkg add $p)" IFS=$PKG_FS read -r name archs cmds layer <<<"$e"
pkg_applies "$archs" || continue
pacman -Qq "$name" &>/dev/null || warn "optional, not installed: $name (omarchy pkg add $name)"
done done
if (( ${#P_DOTFILES[@]} )) && [[ ! -d $HOME/.dotfiles ]]; then
local -A u=(); local m; for m in "${P_DOTFILES[@]}"; do u[$m]=1; done
say ""
say " This profile's roles pair with dotfiles modules: ${!u[*]}"
say " (git.nic-oconnor.com/public/dotfiles) - not run from here."
fi
} }
# ---------------------------------------------------------------- config # ---------------------------------------------------------------- config
step_config() { step_config() {
head_ "Config files -> ~/.config" head_ "Config files -> ~/.config"
local src rel local rel layer
while IFS= read -r -d '' src; do declare -A mine=()
rel=${src#"$REPO/config/"} while IFS=$'\t' read -r rel layer; do
install_file "$src" "$HOME/.config/$rel" install_file "$PROFILES/$layer/config/$rel" "$HOME/.config/$rel"
done < <(find "$REPO/config" -type f -print0 | sort -z) mine[$rel]=1
done < <(owners config)
if (( ! DRY )) && command -v hyprctl >/dev/null && [[ -n ${HYPRLAND_INSTANCE_SIGNATURE:-} ]]; then # A hypr/layers/ file that no layer of this profile provides is left over from
hyprctl reload >/dev/null 2>&1 # a role or profile that no longer applies. It still loads, so say so.
local errs; errs=$(hyprctl configerrors 2>/dev/null) local f
if [[ -n $errs && $errs != "no errors"* ]]; then for f in "$HOME"/.config/hypr/layers/*.lua; do
err "Hyprland reported config errors:"; printf '%s\n' "$errs" | sed 's/^/ /' [[ -e $f ]] || continue
else [[ -n ${mine[hypr/layers/${f##*/}]:-} ]] && continue
ok "hyprctl reload - no config errors" warn "~/.config/hypr/layers/${f##*/} is from no layer of this profile - still loaded; rm it if unwanted"
fi done
fi
hypr_reload
} }
# ---------------------------------------------------------------- themes # ---------------------------------------------------------------- themes
step_themes() { step_themes() {
head_ "Themes -> ~/.config/omarchy/themes" head_ "Themes -> ~/.config/omarchy/themes"
local t name dest local l t name dest
for t in "$REPO"/themes/*/; do declare -A theme=()
[[ -d $t ]] || continue for l in "${LAYERS[@]}"; do
name=$(basename -- "$t") for t in "$PROFILES/$l"/themes/*/; do
[[ -d $t ]] && theme[$(basename -- "$t")]=$t
done
done
(( ${#theme[@]} )) || skip "no layer carries themes"
for name in $(printf '%s\n' "${!theme[@]}" | sort); do
t=${theme[$name]}
dest="$HOME/.config/omarchy/themes/$name" dest="$HOME/.config/omarchy/themes/$name"
if [[ -d $dest ]] && diff -rq "$t" "$dest" >/dev/null 2>&1; then if [[ -d $dest ]] && diff -rq "$t" "$dest" >/dev/null 2>&1; then
skip "$name" skip "$name"
@@ -141,77 +192,103 @@ step_themes() {
ok "$name" ok "$name"
fi fi
done done
say "" if (( ${#P_AETHER[@]} )); then
say " Themes managed by aether (not in this repo - reinstall with \`aether\`):" say ""
say " pink-gt3rs porsche-cayman porsche-rain" say " Themes managed by aether (not in this repo - reinstall with \`aether\`):"
say " Apply one with: omarchy theme set <name>" say " ${P_AETHER[*]}"
fi
(( ${#theme[@]} )) && say " Apply one with: omarchy theme set <name>"
return 0
} }
# ---------------------------------------------------------------- plugins # ---------------------------------------------------------------- plugins
step_plugins() { step_plugins() {
head_ "Shell plugins -> ~/.config/omarchy/plugins" head_ "Shell plugins -> ~/.config/omarchy/plugins"
local dir="$HOME/.config/omarchy/plugins" local dir="$HOME/.config/omarchy/plugins" l
declare -A url=() sha=()
local id u s
for l in "${LAYERS[@]}"; do
[[ -f $PROFILES/$l/plugins.txt ]] || continue
while IFS=$'\t' read -r id u s; do
[[ -z ${id:-} || $id == \#* ]] && continue
url[$id]=$u; sha[$id]=$s
done < "$PROFILES/$l/plugins.txt"
done
if (( ! ${#url[@]} )); then skip "no layer carries plugins"; return 0; fi
[[ -d $dir ]] || run mkdir -p "$dir" [[ -d $dir ]] || run mkdir -p "$dir"
local id url sha p local p
while IFS=$'\t' read -r id url sha; do for id in $(printf '%s\n' "${!url[@]}" | sort); do
[[ -z ${id:-} || $id == \#* ]] && continue p="$dir/$id"; s=${sha[$id]}
p="$dir/$id"
if [[ -d $p/.git ]]; then if [[ -d $p/.git ]]; then
if [[ $(git -C "$p" rev-parse HEAD 2>/dev/null) == "$sha" ]]; then if [[ $(git -C "$p" rev-parse HEAD 2>/dev/null) == "$s" ]]; then
skip "$id @ ${sha:0:7}" skip "$id @ ${s:0:7}"
continue continue
fi fi
if [[ -n $(git -C "$p" status --porcelain 2>/dev/null) ]]; then if [[ -n $(git -C "$p" status --porcelain 2>/dev/null) ]]; then
warn "$id has local changes - leaving it alone (wanted ${sha:0:7})" warn "$id has local changes - leaving it alone (wanted ${s:0:7})"
continue continue
fi fi
run git -C "$p" fetch --quiet origin run git -C "$p" fetch --quiet origin
run git -C "$p" checkout --quiet "$sha" && ok "$id -> ${sha:0:7}" run git -C "$p" checkout --quiet "$s" && ok "$id -> ${s:0:7}"
else else
if (( DRY )); then if (( DRY )); then
printf ' %s? would clone %s -> %s%s\n' "$c_skip" "$id" "${sha:0:7}" "$c_off" printf ' %s? would clone %s -> %s%s\n' "$c_skip" "$id" "${s:0:7}" "$c_off"
continue continue
fi fi
git clone --quiet "$url" "$p" || { err "clone failed: $id ($url)"; continue; } git clone --quiet "${url[$id]}" "$p" || { err "clone failed: $id (${url[$id]})"; continue; }
git -C "$p" checkout --quiet "$sha" || warn "$id: sha $sha not found, left on default branch" git -C "$p" checkout --quiet "$s" || warn "$id: sha $s not found, left on default branch"
ok "$id @ ${sha:0:7}" ok "$id @ ${s:0:7}"
fi fi
done < "$REPO/plugins.txt" done
apply_herdr_hud # Local plugin work that exists on no remote, per layer:
apply_worktree_patch io.github.tallsam.navbar-cat # <id>.bundle a 'local' branch, replayed and checked out
# <id>-worktree.patch uncommitted working-tree changes
# <id>--bin--<file> an untracked file installed as <plugin>/bin/<file>
local f
for l in "${LAYERS[@]}"; do
[[ -d $PROFILES/$l/plugin-patches ]] || continue
for f in "$PROFILES/$l"/plugin-patches/*.bundle; do
[[ -f $f ]] && apply_bundle "$(basename -- "$f" .bundle)" "$f"
done
for f in "$PROFILES/$l"/plugin-patches/*-worktree.patch; do
[[ -f $f ]] || continue
id=$(basename -- "$f"); apply_worktree_patch "${id%-worktree.patch}" "$f"
done
for f in "$PROFILES/$l"/plugin-patches/*--bin--*; do
[[ -f $f ]] || continue
id=$(basename -- "$f")
[[ -d $dir/${id%%--bin--*}/.git ]] || continue
install_file "$f" "$dir/${id%%--bin--*}/bin/${id##*--bin--}" 755
done
done
say "" say ""
say " Bar placement lives in config/omarchy/shell.json, already installed by the" say " Bar placement lives in config/omarchy/shell.json, installed by the config"
say " config step. The shell hot-reloads it; \`omarchy restart shell\` if not." say " step. The shell hot-reloads it; \`omarchy restart shell\` if not."
} }
# indie.herdr-hud carries 10 commits that intentionally never went to the # apply_bundle <id> <bundle> -- replay a plugin's 'local' branch. indie.herdr-hud
# upstream PR, plus an uncommitted working tree. Both are replayed here. # carries commits that intentionally never went to the upstream PR.
apply_herdr_hud() { apply_bundle() {
local p="$HOME/.config/omarchy/plugins/indie.herdr-hud" local id=$1 bundle=$2
local bundle="$REPO/plugin-patches/indie.herdr-hud.bundle" local p="$HOME/.config/omarchy/plugins/$id"
[[ -d $p/.git && -f $bundle ]] || return 0 [[ -d $p/.git ]] || return 0
if git -C "$p" rev-parse --verify --quiet local >/dev/null; then if git -C "$p" rev-parse --verify --quiet local >/dev/null; then
skip "indie.herdr-hud: 'local' branch already present" skip "$id: 'local' branch already present"
elif (( DRY )); then elif (( DRY )); then
printf ' %s? would replay the herdr-hud local branch%s\n' "$c_skip" "$c_off" printf ' %s? would replay the %s local branch%s\n' "$c_skip" "$id" "$c_off"
return 0
else else
git -C "$p" fetch --quiet "$bundle" 'refs/heads/local:refs/heads/local' \ git -C "$p" fetch --quiet "$bundle" 'refs/heads/local:refs/heads/local' \
&& { git -C "$p" checkout --quiet local && ok "indie.herdr-hud: replayed 'local' branch"; } \ && { git -C "$p" checkout --quiet local && ok "$id: replayed 'local' branch"; } \
|| warn "indie.herdr-hud: could not replay the local branch from the bundle" || warn "$id: could not replay the local branch from the bundle"
fi fi
apply_worktree_patch indie.herdr-hud
install_file "$REPO/plugin-patches/indie.herdr-hud--bin--herdr-agents" "$p/bin/herdr-agents" 755
} }
apply_worktree_patch() { apply_worktree_patch() {
local id=$1 local id=$1 patch=$2
local p="$HOME/.config/omarchy/plugins/$id" local p="$HOME/.config/omarchy/plugins/$id"
local patch="$REPO/plugin-patches/$id-worktree.patch"
[[ -d $p/.git && -s $patch ]] || return 0 [[ -d $p/.git && -s $patch ]] || return 0
if git -C "$p" apply --reverse --check "$patch" &>/dev/null; then if git -C "$p" apply --reverse --check "$patch" &>/dev/null; then
skip "$id: working-tree patch already applied" skip "$id: working-tree patch already applied"
@@ -227,52 +304,74 @@ apply_worktree_patch() {
# ---------------------------------------------------------------- bin # ---------------------------------------------------------------- bin
step_bin() { step_bin() {
head_ "Scripts -> ~/.local/bin" head_ "Scripts -> ~/.local/bin"
local f local rel layer n=0
for f in "$REPO"/bin/*; do while IFS=$'\t' read -r rel layer; do
[[ -f $f ]] || continue install_file "$PROFILES/$layer/bin/$rel" "$HOME/.local/bin/$rel" 755
install_file "$f" "$HOME/.local/bin/$(basename -- "$f")" 755 (( n++ ))
done done < <(owners bin)
(( n )) || { skip "no layer carries scripts"; return 0; }
case ":$PATH:" in case ":$PATH:" in
*":$HOME/.local/bin:"*) ;; *":$HOME/.local/bin:"*) ;;
*) warn "~/.local/bin is not on PATH" ;; *) warn "~/.local/bin is not on PATH" ;;
esac esac
say "" [[ -n $(layers_with bin/fix-stuck-displays) ]] && {
say " fix-stuck-displays needs passwordless sudo for 'tee /sys/class/drm/*/status'." say ""
say " omarchy-handoff needs a peer and a paired fingerprint before it does anything:" say " fix-stuck-displays needs passwordless sudo for 'tee /sys/class/drm/*/status'."
say " omarchy-handoff peer <hostname> <ip> then omarchy-handoff doctor" }
say " Its config (~/.config/lan-mouse/) holds a private key and per-machine" return 0
say " fingerprints, so it is not in this repo by design."
} }
# ---------------------------------------------------------------- services # ---------------------------------------------------------------- services
# install_unit <name.service> -- the unit plus its <name>.service.d/*.conf
# drop-ins, from whichever layers own them. Sets UNITS_CHANGED.
UNITS_CHANGED=0
install_unit() {
local name=$1 rel layer dest
while IFS=$'\t' read -r rel layer; do
[[ $rel == "$name" || $rel == "$name.d/"* ]] || continue
dest="$HOME/.config/systemd/user/$rel"
[[ -e $dest ]] && cmp -s "$PROFILES/$layer/systemd/user/$rel" "$dest" || UNITS_CHANGED=1
install_file "$PROFILES/$layer/systemd/user/$rel" "$dest" 0644
done < <(owners systemd/user)
}
# enable_unit <name.service> -- systemctl --user enable, or, with no user
# manager to talk to (an image build), the symlink enable would have made.
enable_unit() {
local name=$1 want
if systemctl --user is-enabled --quiet "$name" 2>/dev/null; then
skip "$name already enabled"; return 0
fi
if (( DRY )); then printf ' %s? would enable %s%s\n' "$c_skip" "$name" "$c_off"; return 0; fi
if systemctl --user enable "$name" >/dev/null 2>&1; then ok "enabled $name"; return 0; fi
want=$(sed -n 's/^WantedBy=//p' "$HOME/.config/systemd/user/$name" | head -1)
[[ -n $want ]] || { err "could not enable $name"; return 1; }
mkdir -p "$HOME/.config/systemd/user/$want.wants"
ln -sf "../$name" "$HOME/.config/systemd/user/$want.wants/$name" && ok "enabled $name (symlink; no user manager)"
}
step_services() { step_services() {
head_ "systemd user units" head_ "systemd user units"
local f name changed=0 local rel layer names=()
for f in "$REPO"/systemd/user/*.service; do while IFS=$'\t' read -r rel layer; do
[[ -f $f ]] || continue [[ $rel == *.service ]] && names+=("$rel")
name=$(basename -- "$f") done < <(owners systemd/user)
local dest="$HOME/.config/systemd/user/$name" (( ${#names[@]} )) || { skip "no layer carries units"; return 0; }
[[ -e $dest ]] && cmp -s "$f" "$dest" || changed=1 UNITS_CHANGED=0
install_file "$f" "$dest" for rel in "${names[@]}"; do install_unit "$rel"; done
done (( UNITS_CHANGED )) && run systemctl --user daemon-reload 2>/dev/null
(( changed )) && run systemctl --user daemon-reload for rel in "${names[@]}"; do enable_unit "$rel"; done
for f in "$REPO"/systemd/user/*.service; do
[[ -f $f ]] || continue
name=$(basename -- "$f")
if systemctl --user is-enabled --quiet "$name" 2>/dev/null; then
skip "$name already enabled"
else
run systemctl --user enable "$name" && ok "enabled $name"
fi
done
say "" say ""
say " Both are WantedBy=graphical-session.target, so they start with Hyprland" say " Units are WantedBy=graphical-session.target, so they start with Hyprland"
say " rather than now. 'systemctl --user start <unit>' to bring one up early." say " rather than now. 'systemctl --user start <unit>' to bring one up early."
} }
# ---------------------------------------------------------------- handoff # ---------------------------------------------------------------- handoff
step_handoff() { step_handoff() {
head_ "omarchy-handoff (lan-mouse)" head_ "omarchy-handoff (lan-mouse)"
local layer; layer=$(layers_with handoff | tail -1)
[[ -n $layer ]] || { skip "not part of this profile"; return 0; }
local hd="$PROFILES/$layer/handoff"
# Self-sufficient on purpose: `install.sh handoff` alone brings input sharing up # Self-sufficient on purpose: `install.sh handoff` alone brings input sharing up
# on an otherwise stock machine, without the packages/config/themes steps that # on an otherwise stock machine, without the packages/config/themes steps that
@@ -280,8 +379,8 @@ step_handoff() {
# the full install.sh afterwards does no extra work. # the full install.sh afterwards does no extra work.
# lan-mouse: the signed package, for the GUI/CLI and as the unpatched fallback. # lan-mouse: the signed package, for the GUI/CLI and as the unpatched fallback.
# rust: only to compile handoff/patch/, removable afterwards. # rust: only to compile handoff/patch/, removable afterwards.
# NOT python-evdev: packages.txt calls it a dependency, but omarchy-air runs # NOT python-evdev: omarchy-air runs this whole setup without it installed, so
# this whole setup without it installed, so it is not required for sharing. # it is not required for sharing.
local need=() p local need=() p
for p in lan-mouse rust; do for p in lan-mouse rust; do
pacman -Qq "$p" &>/dev/null || need+=("$p") pacman -Qq "$p" &>/dev/null || need+=("$p")
@@ -293,11 +392,11 @@ step_handoff() {
skip "lan-mouse, rust" skip "lan-mouse, rust"
fi fi
install_file "$REPO/bin/omarchy-handoff" "$HOME/.local/bin/omarchy-handoff" 0755 install_file "$PROFILES/$layer/bin/omarchy-handoff" "$HOME/.local/bin/omarchy-handoff" 0755
# 1. The patched daemon. systemd/user/lan-mouse.service points at this path, so # 1. The patched daemon. lan-mouse.service points at this path, so it has to
# it has to exist before the services step enables the unit. Per-architecture: # exist before the unit is enabled. Per-architecture: the HP is x86_64 and
# this laptop is x86_64 and omarchy-air is aarch64, so each builds its own. # omarchy-air is aarch64, so each builds its own.
if [[ -x $HOME/.local/bin/lan-mouse-patched ]]; then if [[ -x $HOME/.local/bin/lan-mouse-patched ]]; then
skip "~/.local/bin/lan-mouse-patched (delete it to force a rebuild)" skip "~/.local/bin/lan-mouse-patched (delete it to force a rebuild)"
elif ! command -v cargo >/dev/null; then elif ! command -v cargo >/dev/null; then
@@ -306,27 +405,27 @@ step_handoff() {
printf ' %s? would build ~/.local/bin/lan-mouse-patched from handoff/patch/%s\n' "$c_skip" "$c_off" printf ' %s? would build ~/.local/bin/lan-mouse-patched from handoff/patch/%s\n' "$c_skip" "$c_off"
else else
say " building patched daemon from handoff/patch/ (~1 min; see handoff/patch/REBUILD.md for why)" say " building patched daemon from handoff/patch/ (~1 min; see handoff/patch/REBUILD.md for why)"
if "$REPO/handoff/patch/build.sh"; then ok "~/.local/bin/lan-mouse-patched" if "$hd/patch/build.sh"; then ok "~/.local/bin/lan-mouse-patched"
else err "build failed - the unit will not start until this succeeds"; fi else err "build failed - the unit will not start until this succeeds"; fi
fi fi
# 2. The unit, which points at the binary built above. # 2. The unit (and this machine's drop-ins, e.g. the Air's pointer scale).
install_file "$REPO/systemd/user/lan-mouse.service" \ UNITS_CHANGED=0
"$HOME/.config/systemd/user/lan-mouse.service" 0644 install_unit lan-mouse.service
if (( ! DRY )); then (( UNITS_CHANGED )) && run systemctl --user daemon-reload 2>/dev/null
systemctl --user daemon-reload enable_unit lan-mouse.service
systemctl --user is-enabled --quiet lan-mouse.service 2>/dev/null \
&& skip "lan-mouse.service already enabled" \
|| { systemctl --user enable lan-mouse.service >/dev/null && ok "enabled lan-mouse.service"; }
fi
# 3. The firewall. THE bug that cost a full day: ufw is default-deny inbound, so # 3. The firewall. THE bug that cost a full day: ufw is default-deny inbound, so
# the peer's DTLS handshake dies at the wire and presents EXACTLY like a bad # the peer's DTLS handshake dies at the wire and presents EXACTLY like a bad
# certificate. Authorization is receiver-side only (connect.rs sets # certificate. Authorization is receiver-side only (connect.rs sets
# insecure_skip_verify), so the RECEIVER's firewall must allow 4242/udp or # insecure_skip_verify), so the RECEIVER's firewall must allow 4242/udp or
# that direction is silently dead. # that direction is silently dead.
local ips=("${P_HANDOFF_IPS[@]}")
[[ -n ${HANDOFF_PEER_IPS:-} ]] && read -ra ips <<<"$HANDOFF_PEER_IPS"
if ! command -v ufw >/dev/null; then if ! command -v ufw >/dev/null; then
skip "ufw not installed - nothing to open" skip "ufw not installed - nothing to open"
elif (( ! ${#ips[@]} )); then
warn "no handoff_peer_ips in this profile - set them, or HANDOFF_PEER_IPS='a b'"
else else
local ip rules="" local ip rules=""
# Both reading and changing the ruleset need root. If sudo can't run without # Both reading and changing the ruleset need root. If sudo can't run without
@@ -334,7 +433,7 @@ step_handoff() {
# instead of emitting raw sudo errors and half-applying. # instead of emitting raw sudo errors and half-applying.
if (( ! DRY )) && ! sudo -n true 2>/dev/null && [[ ! -t 0 ]]; then if (( ! DRY )) && ! sudo -n true 2>/dev/null && [[ ! -t 0 ]]; then
warn "sudo needs a password and there is no terminal; run these yourself:" warn "sudo needs a password and there is no terminal; run these yourself:"
for ip in "${HANDOFF_PEER_IPS[@]}"; do for ip in "${ips[@]}"; do
say " sudo ufw allow from $ip to any port 4242 proto udp comment 'lan-mouse peer $ip'" say " sudo ufw allow from $ip to any port 4242 proto udp comment 'lan-mouse peer $ip'"
done done
say "" say ""
@@ -343,7 +442,7 @@ step_handoff() {
return 0 return 0
fi fi
(( DRY )) || rules=$(sudo ufw status 2>/dev/null || true) (( DRY )) || rules=$(sudo ufw status 2>/dev/null || true)
for ip in "${HANDOFF_PEER_IPS[@]}"; do for ip in "${ips[@]}"; do
if [[ $rules == *"4242"*"$ip"* ]]; then if [[ $rules == *"4242"*"$ip"* ]]; then
skip "ufw allows 4242/udp from $ip" skip "ufw allows 4242/udp from $ip"
elif (( DRY )); then elif (( DRY )); then
@@ -359,7 +458,7 @@ step_handoff() {
say "" say ""
say " ~/.config/lan-mouse/ is NOT in this repo (private key + per-machine" say " ~/.config/lan-mouse/ is NOT in this repo (private key + per-machine"
say " fingerprints). Start from handoff/config/config.toml.example-* and:" say " fingerprints). Start from $layer/handoff/config/config.toml.example-* and:"
say " omarchy-handoff fingerprint # give it to the peer, LOWERCASE" say " omarchy-handoff fingerprint # give it to the peer, LOWERCASE"
say " omarchy-handoff pair <peer> <lowercase-sha256>" say " omarchy-handoff pair <peer> <lowercase-sha256>"
say " omarchy-handoff doctor # must pass before arming" say " omarchy-handoff doctor # must pass before arming"
@@ -369,12 +468,12 @@ step_handoff() {
# ---------------------------------------------------------------- system # ---------------------------------------------------------------- system
step_system() { step_system() {
head_ "System drop-ins (needs sudo)" head_ "System drop-ins -> /etc (needs sudo)"
local src rel dest local rel layer dest n=0 sysctl=0 modprobe=0
while IFS= read -r -d '' src; do while IFS=$'\t' read -r rel layer; do
rel=${src#"$REPO/system/"} (( n++ ))
dest="/etc/$rel" dest="/etc/$rel"
if [[ -e $dest ]] && cmp -s "$src" "$dest"; then if [[ -e $dest ]] && cmp -s "$PROFILES/$layer/system/$rel" "$dest"; then
skip "$dest" skip "$dest"
continue continue
fi fi
@@ -382,27 +481,85 @@ step_system() {
printf ' %s? would write %s%s\n' "$c_skip" "$dest" "$c_off" printf ' %s? would write %s%s\n' "$c_skip" "$dest" "$c_off"
continue continue
fi fi
sudo install -D -m 0644 "$src" "$dest" && ok "$dest" sudo install -D -m 0644 "$PROFILES/$layer/system/$rel" "$dest" && ok "$dest"
done < <(find "$REPO/system" -type f -print0 | sort -z) [[ $rel == sysctl.d/* ]] && sysctl=1
[[ $rel == modprobe.d/* ]] && modprobe=1
done < <(owners system)
(( n )) || { skip "no layer carries /etc drop-ins"; return 0; }
if (( ! DRY )); then (( sysctl )) && sudo sysctl --system >/dev/null 2>&1 && ok "sysctl reloaded"
sudo sysctl --system >/dev/null 2>&1 && ok "sysctl reloaded" if (( modprobe )); then
say "" say ""
say " hid_apple.conf (fnmode=2: F-keys act as F-keys) only takes effect once the" say " modprobe.d options (hid_apple fnmode, appledrm notch) only take effect once"
say " initramfs is rebuilt or the module reloads:" say " the initramfs is rebuilt or the module reloads:"
say " sudo mkinitcpio -P # or: sudo modprobe -r hid_apple && sudo modprobe hid_apple" say " sudo mkinitcpio -P # or: sudo modprobe -r <module> && sudo modprobe <module>"
fi fi
} }
# ---------------------------------------------------------------- setup
# Each layer's setup.sh, for state outside ~/.config (markers, gsettings, a
# first-run theme). Run last, in layer order.
step_setup() {
head_ "Layer setup scripts"
local l n=0
for l in "${LAYERS[@]}"; do
[[ -x $PROFILES/$l/setup.sh ]] || continue
(( n++ ))
say " $l/setup.sh"
OMARCHY_SETUP_DRY=$DRY OMARCHY_SETUP_REPO=$REPO OMARCHY_SETUP_LAYER="$PROFILES/$l" \
"$PROFILES/$l/setup.sh" || err "$l/setup.sh failed"
done
(( n )) || skip "no layer has a setup.sh"
return 0
}
# ---------------------------------------------------------------- show
show() {
say "${c_hd}$PROFILE${c_off} $(layer_description "$PROFILE")"
[[ -n $P_ARCH ]] && say " arch: $P_ARCH"
say ""
say "Layers, in order (later wins):"
local l; for l in "${LAYERS[@]}"; do printf ' %-26s %s\n' "$l" "$(layer_description "$l")"; done
local sub rel layer
for sub in config bin systemd/user system; do
local any=0
while IFS=$'\t' read -r rel layer; do
(( any )) || { say ""; say "$sub/"; any=1; }
printf ' %-52s %s\n' "$rel" "$layer"
done < <(owners "$sub")
done
}
list_profiles() {
local kind d
for kind in machines roles features; do
say "${c_hd}$kind${c_off}"
for d in "$PROFILES/$kind"/*/; do
d=${d%/}; printf ' %-18s %s\n' "${d##*/}" "$(layer_description "$kind/${d##*/}")"
done
done
say "${c_hd}shared${c_off}"
for d in base personal personal-desktop; do printf ' %-18s %s\n' "$d" "$(layer_description "$d")"; done
}
# ---------------------------------------------------------------- main # ---------------------------------------------------------------- main
main() { main() {
local want=() local want=() profile="" roles_arg="" roles_given=0 show_only=0 a
for a in "$@"; do while (( $# )); do
a=$1; shift
case $a in case $a in
--dry-run|-n) DRY=1 ;; --dry-run|-n) DRY=1 ;;
--list|-l) printf '%s\n' "${STEPS[@]}"; exit 0 ;; --no-clobber) NO_CLOBBER=1 ;;
-h|--help) sed -n '2,15p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; --force) FORCE=1 ;;
-*) err "unknown option: $a"; exit 2 ;; --profile|-p) profile=${1:?--profile needs a name}; shift ;;
--profile=*) profile=${a#*=} ;;
--roles|-r) roles_arg=${1-}; roles_given=1; shift ;;
--roles=*) roles_arg=${a#*=}; roles_given=1 ;;
--show) show_only=1 ;;
--profiles) list_profiles; exit 0 ;;
--list|-l) printf '%s\n' "${STEPS[@]}"; exit 0 ;;
-h|--help) awk 'NR > 1 && /^#/ { sub(/^# ?/, ""); print; next } NR > 1 { exit }' "${BASH_SOURCE[0]}"; exit 0 ;;
-*) err "unknown option: $a"; exit 2 ;;
*) *)
if [[ " ${STEPS[*]} " == *" $a "* ]]; then want+=("$a") if [[ " ${STEPS[*]} " == *" $a "* ]]; then want+=("$a")
else err "unknown step: $a (one of: ${STEPS[*]})"; exit 2; fi ;; else err "unknown step: $a (one of: ${STEPS[*]})"; exit 2; fi ;;
@@ -410,15 +567,49 @@ main() {
done done
(( ${#want[@]} )) || want=("${STEPS[@]}") (( ${#want[@]} )) || want=("${STEPS[@]}")
say "${c_hd}omarchy-setup${c_off} repo=$REPO" local saved_profile="" saved_roles=""
if def=$(default_profile); then IFS=$'\t' read -r saved_profile saved_roles <<<"$def"; fi
if [[ -z $profile ]]; then
profile=$saved_profile
[[ -n $profile ]] || { err "no --profile given and none matches host $(hostname)"; list_profiles; exit 2; }
fi
local rp; rp=$(find_layer "$profile") || { err "no such profile: $profile"; list_profiles; exit 2; }
# No --roles reuses the roles last installed with this profile; --roles ""
# means none (a Coder workspace with every extra role deselected).
(( roles_given )) || [[ $rp != "$saved_profile" ]] || roles_arg=$saved_roles
ROLES=()
[[ -n $roles_arg ]] && IFS=', ' read -ra ROLES <<<"$roles_arg"
resolve "$rp" "${ROLES[@]}" || exit 2
load_settings
if (( show_only )); then show; exit 0; fi
say "${c_hd}omarchy-setup${c_off} profile=$PROFILE${ROLES:+ roles=${ROLES[*]}} repo=$REPO"
say " layers: ${LAYERS[*]}"
(( DRY )) && warn "dry run - nothing will be changed" (( DRY )) && warn "dry run - nothing will be changed"
(( NO_CLOBBER )) && warn "--no-clobber: files that differ from the profile are kept"
command -v omarchy >/dev/null || warn "omarchy not found; this looks like a non-Omarchy host" command -v omarchy >/dev/null || warn "omarchy not found; this looks like a non-Omarchy host"
for s in "${want[@]}"; do "step_$s"; done if [[ -n $P_ARCH && $P_ARCH != "$(uname -m)" ]]; then
err "$PROFILE is for $P_ARCH, this machine is $(uname -m)"
(( FORCE )) || { say " --force to apply it anyway"; exit 2; }
fi
local host; host=$(hostname 2>/dev/null || cat /etc/hostname)
if (( ${#P_HOSTNAMES[@]} )) && [[ " ${P_HOSTNAMES[*]} " != *" $host "* ]]; then
warn "$PROFILE is for ${P_HOSTNAMES[*]}, this host is $host"
fi
# Canonical order whatever order they were named in: setup after config, etc.
for s in "${STEPS[@]}"; do [[ " ${want[*]} " == *" $s "* ]] && "step_$s"; done
(( DRY )) || save_state
head_ "Done" head_ "Done"
say " Re-running is safe. Backups from this run are suffixed .bak.$STAMP" say " Re-running is safe. Backups from this run are suffixed .bak.$STAMP"
say " Pull live changes back into the repo with ./capture.sh" say " Pull live changes back into the repo with ./capture.sh"
# Non-zero when any step reported an error, so an image build fails on it.
if (( ERRORS )); then err "$ERRORS error(s) above"; exit 1; fi
} }
main "$@" main "$@"
+183
View File
@@ -0,0 +1,183 @@
# shellcheck shell=bash disable=SC1090
# Profile resolution, shared by install.sh and capture.sh. Sourced, not run.
#
# A profile is a directory under profiles/ with a `profile` file (bash, sourced)
# and any of: config/ (mirrors ~/.config), themes/, plugins.txt,
# plugin-patches/, bin/, systemd/user/, system/ (mirrors /etc), handoff/,
# packages.txt, setup.sh. `include=(...)` names the layers it builds on.
#
# Resolving a profile gives an ordered list of layers: each layer's includes
# first (depth first, each layer once), then any --roles, then the profile
# itself. For a path that several layers provide, the LAST layer wins, so a
# machine always overrides the roles and shared layers under it.
#
# Expects REPO to be set, and err/warn helpers from the caller.
PROFILES=$REPO/profiles
STATE_FILE=$HOME/.config/omarchy-setup/profile
LAYERS=() # resolved layer paths relative to profiles/, in order
PROFILE="" # the profile asked for, e.g. machines/hp-laptop
ROLES=() # extra roles asked for, e.g. devops ai-agents
_resolving=()
# find_layer <name> -> layer path relative to profiles/. Accepts the full path
# (machines/hp-laptop) or a bare name looked up in machines/, roles/, features/.
find_layer() {
local c
for c in "$1" "machines/$1" "roles/$1" "features/$1"; do
[[ -f $PROFILES/$c/profile ]] && { printf '%s\n' "$c"; return 0; }
done
return 1
}
layer_includes() {
( include=(); source "$PROFILES/$1/profile"; printf '%s\n' "${include[@]}" )
}
layer_description() {
( description=""; source "$PROFILES/$1/profile"; printf '%s\n' "$description" )
}
_add_layer() {
local rel l inc
rel=$(find_layer "$1") || { err "no such layer: $1"; return 1; }
for l in "${LAYERS[@]}"; do [[ $l == "$rel" ]] && return 0; done
[[ " ${_resolving[*]} " == *" $rel "* ]] && { err "include cycle through $rel"; return 1; }
_resolving+=("$rel")
while IFS= read -r inc; do
[[ -n $inc ]] || continue
_add_layer "$inc" || return 1
done < <(layer_includes "$rel")
LAYERS+=("$rel")
}
# resolve <profile> [role...] -> fills LAYERS
resolve() {
local rel inc r
LAYERS=(); _resolving=()
rel=$(find_layer "$1") || { err "no such profile: $1 (./install.sh --profiles lists them)"; return 1; }
PROFILE=$rel
_resolving+=("$rel")
while IFS= read -r inc; do
[[ -n $inc ]] || continue
_add_layer "$inc" || return 1
done < <(layer_includes "$rel")
for r in "${@:2}"; do _add_layer "roles/${r#roles/}" || return 1; done
LAYERS+=("$rel")
}
# Settings from the layers' profile files. Arrays accumulate across layers;
# handoff_peer_ips and arch come from the last layer that sets them.
P_ARCH=""; P_HOSTNAMES=(); P_HANDOFF_IPS=(); P_DOTFILES=(); P_AETHER=()
load_settings() {
local l
P_ARCH=""; P_HOSTNAMES=(); P_HANDOFF_IPS=(); P_DOTFILES=(); P_AETHER=()
for l in "${LAYERS[@]}"; do
local arch="" hostnames=() handoff_peer_ips=() dotfiles=() aether_themes=() include=() description=""
source "$PROFILES/$l/profile"
[[ -n $arch ]] && P_ARCH=$arch
(( ${#hostnames[@]} )) && P_HOSTNAMES=("${hostnames[@]}")
(( ${#handoff_peer_ips[@]} )) && P_HANDOFF_IPS=("${handoff_peer_ips[@]}")
P_DOTFILES+=("${dotfiles[@]}")
P_AETHER+=("${aether_themes[@]}")
done
}
# Which profile to use when none is given: the one recorded by the last
# install on this machine, else the machine profile whose hostnames= matches.
# Prints "profile<TAB>role,role".
default_profile() {
if [[ -f $STATE_FILE ]]; then
local profile="" roles=""
source "$STATE_FILE"
[[ -n $profile ]] && { printf '%s\t%s\n' "$profile" "$roles"; return 0; }
fi
local f host; host=$(hostname 2>/dev/null || cat /etc/hostname)
for f in "$PROFILES"/machines/*/profile; do
if ( hostnames=(); source "$f"; [[ " ${hostnames[*]} " == *" $host "* ]] ); then
f=${f%/profile}; printf '%s\t\n' "${f#"$PROFILES/"}"; return 0
fi
done
return 1
}
save_state() {
mkdir -p "${STATE_FILE%/*}"
local IFS=,
printf '# Written by omarchy-setup/install.sh; read when no --profile is given.\nprofile=%q\nroles=%q\n' \
"$PROFILE" "${ROLES[*]}" > "$STATE_FILE"
}
# owners <subdir> -> "relpath<TAB>layer" for every file under <layer>/<subdir>
# across the layers, the last layer winning each relpath. Sorted by relpath.
owners() {
local sub=$1 l f
declare -A own=()
for l in "${LAYERS[@]}"; do
[[ -d $PROFILES/$l/$sub ]] || continue
while IFS= read -r -d '' f; do
own[${f#"$PROFILES/$l/$sub/"}]=$l
done < <(find "$PROFILES/$l/$sub" -type f -print0)
done
for f in "${!own[@]}"; do printf '%s\t%s\n' "$f" "${own[$f]}"; done | sort
}
# Every layer that has <path> (a file or directory), in order.
layers_with() {
local l
for l in "${LAYERS[@]}"; do [[ -e $PROFILES/$l/$1 ]] && printf '%s\n' "$l"; done
}
# Parse every layer's packages.txt into PKG_<section> arrays of
# "name<US>archs<US>cmds<US>layer", US being $PKG_FS. Not a tab: tab is IFS
# whitespace, so `read` would collapse an empty archs field and shift the rest.
# [ignore] lines may hold several names.
PKG_FS=$'\x1f'
PKG_repo=(); PKG_aur=(); PKG_optional=(); PKG_ignore=()
load_packages() {
local l line section tok name archs cmds
PKG_repo=(); PKG_aur=(); PKG_optional=(); PKG_ignore=()
declare -A seen=()
for l in "${LAYERS[@]}"; do
[[ -f $PROFILES/$l/packages.txt ]] || continue
section=""
while IFS= read -r line; do
line=${line%%#*}
read -ra tok <<<"$line"
(( ${#tok[@]} )) || continue
case ${tok[0]} in
'[repo]'|'[aur]'|'[optional]'|'[ignore]') section=${tok[0]//[\[\]]/}; continue ;;
esac
[[ -n $section ]] || continue
if [[ $section == ignore ]]; then
for name in "${tok[@]}"; do PKG_ignore+=("$name$PKG_FS$PKG_FS$PKG_FS$l"); done
continue
fi
name=${tok[0]}; archs=""; cmds=""
for t in "${tok[@]:1}"; do
case $t in
cmd=*) cmds=${t#cmd=} ;;
x86_64|aarch64) archs+="$t," ;;
*) warn "$l/packages.txt: unknown token '$t' on $name" ;;
esac
done
[[ -n ${seen[$name]:-} ]] && continue
seen[$name]=1
local -n arr="PKG_$section"
arr+=("$name$PKG_FS${archs%,}$PKG_FS$cmds$PKG_FS$l")
unset -n arr
done < "$PROFILES/$l/packages.txt"
done
}
# pkg_applies <archs> -> true when this machine's arch is in the list (or no list)
pkg_applies() { [[ -z $1 || ",$1," == *",$(uname -m),"* ]]; }
# pkg_cmd_present <cmds> -> true when any of the comma-separated commands exists
pkg_cmd_present() {
local c
[[ -n $1 ]] || return 1
for c in ${1//,/ }; do command -v "$c" >/dev/null 2>&1 && return 0; done
return 1
}
-59
View File
@@ -1,59 +0,0 @@
# Packages this laptop has beyond the Omarchy 4.x baseline.
#
# Computed as: `pacman -Qqe` minus the omarchy meta-package's dependencies and
# install/omarchy-base.packages. Lines are "name # why". Blank lines and
# comments are ignored. Section markers drive install.sh --packages:
# [repo] official repos + the omarchy repo -> omarchy pkg add
# [aur] AUR only -> omarchy pkg aur add
# [optional] installed here, not required for the desktop to work
#
# Deliberately NOT listed: base, base-devel, sudo, mkinitcpio, linux-firmware,
# efibootmgr, devtools, cmake, ninja (toolchain/base, present on any Omarchy
# install or pulled in as a dependency), and the remote-access stack
# (wayvnc, sunshine, freerdp, remmina, omarchy-remote-desktop-backend,
# pipewire-debug) which is out of this repo's scope.
[repo]
# --- omarchy-handoff: builds the patched lan-mouse daemon (handoff/patch/) ---
rust # only to compile handoff/patch/; ~250MiB installed, removable after
# --- hardware: this laptop is Intel + an Anker USB-C dock ---
linux-omarchy # kernel this install actually boots
linux-omarchy-headers # needed to build out-of-tree modules against it
intel-ucode # CPU microcode
vulkan-intel # Intel Vulkan driver
lib32-vulkan-intel # 32-bit Vulkan, for Steam/Proton
sof-firmware # Sound Open Firmware - required for this laptop's audio
thermald # Intel thermal daemon; without it the CPU throttles hard
zram-generator # compressed swap in RAM
# --- audio: full PipeWire stack with the Pulse/JACK/ALSA shims ---
pipewire-pulse
pipewire-alsa
pipewire-jack
libpulse
gst-plugin-pipewire
# --- desktop / input ---
lan-mouse # keyboard+mouse sharing driven by bin/omarchy-handoff
python-evdev # lan-mouse / input tooling dependency
wayland-utils # wayland-info, used when debugging outputs
ghostty # terminal (alongside Omarchy's default)
qmltermwidget # embedded terminal widget; indie.herdr-hud needs it
qt6-connectivity # Bluetooth/QML bits for the omapods plugin
qt6-tools # qmllint etc. when editing plugin QML
openbsd-netcat # nc for kb2uka.port-doctor checks
# --- apps ---
1password
1password-cli # `op`, which the dotfiles repo's secrets module uses
visual-studio-code-bin
bun-bin
[aur]
hyprmoncfg-bin # display profile daemon; pairs with crmne.hyprmoncfg plugin
netbird-bin # NetBird client for the covert.vpn private network
voxtype-bin # push-to-talk voice-to-text (voxtype.service)
[optional]
steam # games; pulls a large 32-bit dependency tree
@@ -1,48 +0,0 @@
diff --git i/Cat.qml w/Cat.qml
index 58ed7f6..f1bbe9b 100644
--- i/Cat.qml
+++ w/Cat.qml
@@ -30,13 +30,18 @@ Item {
property string omarchyPath: ""
readonly property string pluginId: manifest && manifest.id ? String(manifest.id) : "io.github.tallsam.navbar-cat"
+ // The shell strips `__sourceDir` from third-party manifests, so fall back to
+ // this file's own directory.
+ readonly property string pluginDir: manifest && manifest.__sourceDir
+ ? String(manifest.__sourceDir)
+ : String(Qt.resolvedUrl(".")).replace(/^file:\/\//, "").replace(/\/$/, "")
readonly property string assetDir: {
- var dir = manifest && manifest.__sourceDir ? String(manifest.__sourceDir) : ""
+ var dir = pluginDir
if (dir === "") return ""
return (dir.indexOf("file://") === 0 ? dir : "file://" + dir) + "/assets"
}
readonly property string helper: {
- var dir = manifest && manifest.__sourceDir ? String(manifest.__sourceDir) : ""
+ var dir = pluginDir
if (dir === "") return ""
return dir.replace(/^file:\/\//, "") + "/bin/navbar-cat-cursor"
}
@@ -52,8 +57,21 @@ Item {
// Settings live inline on this plugin's entry in shell.json's `plugins[]`,
// per the shell's storage rules.
+ // Third-party plugins get a scoped `shell` without `shellConfig`, so read
+ // shell.json directly when the shell does not hand it over.
+ FileView {
+ id: shellJson
+ path: Quickshell.env("HOME") + "/.config/omarchy/shell.json"
+ watchChanges: true
+ onFileChanged: reload()
+ }
+ readonly property var fileConfig: {
+ try { return JSON.parse(shellJson.text()) } catch (e) { return null }
+ }
+
readonly property var config: {
- var settings = shell && shell.shellConfig ? shell.shellConfig.plugins : null
+ var source = shell && shell.shellConfig ? shell.shellConfig : root.fileConfig
+ var settings = source ? source.plugins : null
if (Array.isArray(settings)) {
for (var i = 0; i < settings.length; i++) {
if (settings[i] && String(settings[i].id || "") === root.pluginId) return settings[i]
@@ -28,16 +28,6 @@
-- o.bind("SUPER + H", nil, "voxtype record toggle") -- o.bind("SUPER + H", nil, "voxtype record toggle")
-- o.bind("SUPER + PERIOD", nil, "omarchy-shell shell toggle omarchy.emojis") -- o.bind("SUPER + PERIOD", nil, "omarchy-shell shell toggle omarchy.emojis")
-- Open Claude instead of the stock ChatGPT webapp. -- omarchy-setup keeps this file at stock on purpose. Profile layers add their
hl.unbind("SUPER + SHIFT + A") -- bindings as hypr/layers/<NN>-<layer>.lua, so a binding added here AND by a
o.bind("SUPER + SHIFT + A", "Claude", { webapp = "https://claude.ai" }) -- layer would fire twice. Put machine-only bindings in that machine's layer.
-- Herdr HUD overlay (indie.herdr-hud native plugin, local branch).
o.bind("SUPER + H", "Toggle Herdr HUD", "omarchy-shell shell toggle indie.herdr-hud '{}'")
o.bind("SUPER + ALT + H", "Show or hide Herdr HUD button", "omarchy-shell shell call indie.herdr-hud toggleVisibility '{}'")
o.bind("SUPER + CTRL + DOWN", "Herdr HUD: next agent", "omarchy-shell shell call indie.herdr-hud nextAgent '{}'")
o.bind("SUPER + CTRL + UP", "Herdr HUD: previous agent", "omarchy-shell shell call indie.herdr-hud previousAgent '{}'")
-- Handoff: share this keyboard/mouse with the other Omarchy laptop.
-- SUPER+K is already "Keybindings", so this takes SUPER+SHIFT+K.
o.bind("SUPER + SHIFT + K", "Toggle Handoff (keyboard/mouse sharing)", "omarchy-handoff toggle")
@@ -22,6 +22,12 @@ require("hypr.bindings")
require("hypr.looknfeel") require("hypr.looknfeel")
require("hypr.autostart") require("hypr.autostart")
-- omarchy-setup layers: every profile layer (role, machine, feature) puts its
-- Hyprland settings in its own file under hypr/layers/, so layers never fight
-- over bindings.lua or looknfeel.lua. Loaded in name order, after the files
-- above; reload=true makes `hyprctl reload` re-run them.
require("default.hypr.require_all").files(os.getenv("HOME") .. "/.config/hypr/layers", "hypr.layers", { reload = true })
-- Toggle config flags dynamically. -- Toggle config flags dynamically.
require("default.hypr.toggles") require("default.hypr.toggles")
+4
View File
@@ -0,0 +1,4 @@
# Every machine, including the Coder desktops. Keeps hyprland.lua and
# bindings.lua at stock plus the hypr/layers/ loader, so every other layer adds
# Hyprland settings as its own file instead of editing a shared one.
description="Shared by every machine: the hypr/layers/ loader"
@@ -0,0 +1,4 @@
-- handoff feature: share this keyboard/mouse with the other Omarchy laptop.
-- SUPER+K is already Omarchy's "Keybindings", so this takes SUPER+SHIFT+K.
-- Same chord on both laptops so muscle memory carries across.
o.bind("SUPER + SHIFT + K", "Toggle Handoff (keyboard/mouse sharing)", "omarchy-handoff toggle")
+4
View File
@@ -0,0 +1,4 @@
# See machines/hp-laptop/packages.txt for the format.
[repo]
lan-mouse # keyboard+mouse sharing driven by bin/omarchy-handoff; also the unpatched fallback
rust # only to compile handoff/patch/; ~250MiB installed, removable after
+4
View File
@@ -0,0 +1,4 @@
# omarchy-handoff: lan-mouse keyboard/mouse sharing between the two laptops.
# Machines that include this set handoff_peer_ips to the OTHER laptop's
# addresses; install.sh's handoff step opens 4242/udp in ufw for exactly those.
description="Keyboard/mouse sharing between the laptops (lan-mouse, patched)"
@@ -19,11 +19,9 @@ Environment=LAN_MOUSE_LOG_LEVEL=info
# Falls back to nothing: if the build is missing the unit fails loudly rather # Falls back to nothing: if the build is missing the unit fails loudly rather
# than silently running the unpatched binary with the wrong pointer feel. # than silently running the unpatched binary with the wrong pointer feel.
ExecStart=%h/.local/bin/lan-mouse-patched daemon ExecStart=%h/.local/bin/lan-mouse-patched daemon
# Multiplier for the residual logical-ppi difference between the two screens # LAN_MOUSE_POINTER_SCALE (default 1.0) is per-machine, so it is not set here:
# (default 1.0 = upstream). omarchy-air is 140.1 logical ppi at scale 1.6 vs # a machine that needs it ships lan-mouse.service.d/pointer-scale.conf
# ~95.8 for the 23" LG, so 0.7 makes motion cover the same PHYSICAL distance. # (see machines/macbook-air-m2).
# Per-machine: adjust or drop this line on the other host.
Environment=LAN_MOUSE_POINTER_SCALE=0.7
Restart=on-failure Restart=on-failure
RestartSec=2 RestartSec=2
# Output goes to the journal on purpose. The previous autostart.lua path sent # Output goes to the journal on purpose. The previous autostart.lua path sent
@@ -0,0 +1,5 @@
-- coder-desktop: a streamed VM with no GPU. vga=virtio without virgl, so
-- Hyprland draws on the CPU (llvmpipe) and every frame is a full-screen copy
-- into the scanout buffer. Anything that animates keeps the CPU busy, so no
-- window animations.
hl.config({ animations = { enabled = false } })
@@ -0,0 +1,20 @@
-- See https://wiki.hypr.land/Configuring/Basics/Monitors/
-- List current monitors and supported resolutions with: hyprctl monitors all
local omarchy_gdk_scale = 1
local omarchy_monitor_scale = "auto"
hl.env("GDK_SCALE", tostring(omarchy_gdk_scale))
hl.monitor({ output = "", mode = "preferred", position = "auto", scale = omarchy_monitor_scale })
-- Configure a specific monitor.
-- hl.monitor({ output = "DP-2", mode = "2560x1440@144", position = "0x0", scale = 1 })
-- Portrait/rotated secondary monitor (transform: 1 = 90°, 3 = 270°).
-- hl.monitor({ output = "DP-2", mode = "preferred", position = "auto", scale = 1, transform = 1 })
-- Streamed desktop: the VM's virtio display, fixed to the stream size.
-- MUST match --manual-width/--manual-height in the image's selkies.service
-- (covert-cloud: platform/workspaces/omarchy-desktop/image/systemd/user/), and
-- the image build fails if this line is missing.
hl.monitor({ output = "Virtual-1", mode = "1920x1080@60", position = "0x0", scale = 1 })
+15
View File
@@ -0,0 +1,15 @@
# Coder desktops: Omarchy in a Proxmox VM, streamed by Selkies, reached only
# through Coder. Built by covert-cloud's platform/workspaces/omarchy-desktop/
# image, which runs `install.sh --profile coder-desktop` at a pinned commit.
#
# This layer is the desktop's Omarchy look and behaviour. The image keeps the
# plumbing: Selkies, the Coder agent, the Super key stand-in, the start page,
# autologin and the lockdown (no SSH, no file transfer, locked password).
#
# Extra roles come from the Coder template's "Roles" parameter, applied at
# every workspace start with --roles and --no-clobber. Nothing personal
# (personal/, personal-desktop/, handoff) belongs here: other people use these.
description="Coder desktop VM (streamed by Selkies): no GPU, fixed 1920x1080"
include=(base roles/developer)
hostnames=(omarchy-desktop)
arch=x86_64
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
# coder-desktop setup, run by install.sh's `setup` step after the files are in
# place. Everything here is state outside ~/.config. Idempotent; honours
# OMARCHY_SETUP_DRY=1.
set -uo pipefail
run() { if [[ ${OMARCHY_SETUP_DRY:-0} == 1 ]]; then echo " ? would: $*"; else "$@"; fi; }
state=$HOME/.local/state/omarchy-setup
export OMARCHY_PATH=${OMARCHY_PATH:-/usr/share/omarchy}
# No idle lock or screensaver: Omarchy's stay-awake mode. The desktop user has
# no password (the image locks it), so a locked screen could never be unlocked,
# and an idle lock would darken the stream. The image build checks for this.
marker=$HOME/.local/state/omarchy/indicators/stay-awake
if [[ -e $marker ]]; then
echo " = stay-awake"
else
run mkdir -p "${marker%/*}" && run touch "$marker" && echo " + stay-awake"
fi
# Reduced motion, so Chromium reports prefers-reduced-motion and pages that
# honour it stop animating (see layers/80-coder-desktop.lua for why). No
# session during the image build, hence dbus-run-session.
if command -v gsettings >/dev/null; then
gs=(gsettings); [[ -n ${DBUS_SESSION_BUS_ADDRESS:-} ]] || gs=(dbus-run-session -- gsettings)
if [[ $("${gs[@]}" get org.gnome.desktop.interface enable-animations 2>/dev/null) == false ]]; then
echo " = gtk animations off"
else
run "${gs[@]}" set org.gnome.desktop.interface enable-animations false && echo " + gtk animations off"
fi
fi
# Default theme: Omarchy's Retro 82. Only on the first apply, because install.sh
# also runs at every workspace start (for the roles parameter) and must not undo
# a theme the developer picked. The image also installs the Covert Cloud themes
# for anyone who chooses them.
if [[ -e $state/coder-desktop-theme ]]; then
echo " = default theme already set once (left alone)"
else
run env OMARCHY_THEME_HEADLESS=1 "$OMARCHY_PATH/bin/omarchy-theme-set" "Retro 82" \
&& run mkdir -p "$state" && run touch "$state/coder-desktop-theme" && echo " + theme: Retro 82"
fi
@@ -34,7 +34,7 @@ fi
systemctl --user stop hyprmoncfgd systemctl --user stop hyprmoncfgd
for d in "${stuck[@]}"; do for d in "${stuck[@]}"; do
out=${d##*/card*-} out=${d##*/}; out=${out#card*-}
log "releasing $out" log "releasing $out"
echo on | sudo -n tee "$d/status" >/dev/null echo on | sudo -n tee "$d/status" >/dev/null
wait_for 5 sh -c "hyprctl monitors all | grep -q '^Monitor $out '" || log "$out never appeared in Hyprland" wait_for 5 sh -c "hyprctl monitors all | grep -q '^Monitor $out '" || log "$out never appeared in Hyprland"
@@ -11,7 +11,7 @@ hl.monitor({ output = "eDP-1", mode = "preferred", position = "0x0", scale = 1.2
-- displays unrecoverable before. -- displays unrecoverable before.
local omarchy_gdk_scale = 1 local omarchy_gdk_scale = 1
local omarchy_monitor_scale = 1.25 local omarchy_monitor_scale = 1
hl.env("GDK_SCALE", tostring(omarchy_gdk_scale)) hl.env("GDK_SCALE", tostring(omarchy_gdk_scale))
+48
View File
@@ -0,0 +1,48 @@
# HP laptop (omarchy-laptop) packages beyond the Omarchy 4.x baseline and the
# packages its other layers already list.
#
# Format (every layer's packages.txt): "name [arch...] [cmd=command] # why".
# arch only install on these architectures (x86_64, aarch64)
# cmd=X skip when command X is already on PATH (e.g. a dotfiles shim)
# Sections:
# [repo] official repos + the omarchy repo -> omarchy pkg add
# [aur] AUR only -> omarchy pkg aur add
# [optional] installed here, not required for the desktop to work
# [ignore] installed here on purpose but not managed (capture.sh stays quiet)
[repo]
# --- hardware: Intel + an Anker USB-C dock ---
linux-omarchy # kernel this install actually boots
linux-omarchy-headers # needed to build out-of-tree modules against it
intel-ucode # CPU microcode
vulkan-intel # Intel Vulkan driver
lib32-vulkan-intel # 32-bit Vulkan, for Steam/Proton
sof-firmware # Sound Open Firmware - required for this laptop's audio
thermald # Intel thermal daemon; without it the CPU throttles hard
zram-generator # compressed swap in RAM
# --- audio: full PipeWire stack with the Pulse/JACK/ALSA shims ---
pipewire-pulse
pipewire-alsa
pipewire-jack
libpulse
gst-plugin-pipewire
# --- desktop / input ---
python-evdev # input tooling used while debugging lan-mouse (not required by it)
wayland-utils # wayland-info, used when debugging outputs
ghostty # terminal (alongside Omarchy's default)
# --- apps ---
1password
1password-cli # `op`, which the dotfiles repo's secrets module uses
bun-bin
[optional]
steam # games; pulls a large 32-bit dependency tree
[ignore]
# toolchain/base, present on any Omarchy install or pulled in as a dependency
base base-devel sudo omarchy mkinitcpio linux-firmware efibootmgr devtools cmake ninja
# the remote-access stack, out of this repo's scope
wayvnc sunshine freerdp remmina omarchy-remote-desktop-backend pipewire-debug
+8
View File
@@ -0,0 +1,8 @@
# HP laptop (omarchy-laptop): Intel, an Anker USB-C dock, the LG IPS236 on HDMI
# and the LG TV through the dock. The machine this repo was first captured from.
description="HP laptop (omarchy-laptop): Intel, Anker dock, LG displays"
include=(base personal personal-desktop features/handoff roles/developer roles/devops roles/ai-agents)
hostnames=(omarchy-laptop)
arch=x86_64
# The peer for omarchy-handoff is the Air (Wi-Fi only).
handoff_peer_ips=(192.168.1.49)
@@ -0,0 +1,96 @@
onboarding = false
# Mirrors the Omarchy tmux config in config/tmux/tmux.conf
# tmux session -> herdr workspace, tmux window -> herdr tab, tmux pane -> herdr pane
[theme]
# tmux ran on the terminal's own palette (bg=default, fg=default, ANSI blue accents)
name = "terminal"
[theme.custom]
# The active tab is drawn as panel_bg text on an accent background, so panel_bg
# has to be dark for it to read - same colors as status-left's "#[fg=black,bg=blue]"
panel_bg = "black"
[terminal]
# Matches -c "#{pane_current_path}" on every split, window, and session
new_cwd = "follow"
[keys]
prefix = "ctrl+space"
# Config and help
reload_config = "prefix+q"
help = "prefix+?"
detach = "prefix+d"
# Copy mode
copy_mode = "prefix+["
# Panes
split_horizontal = ["prefix+h", "alt+enter"]
split_vertical = ["prefix+v", "alt+shift+enter"]
close_pane = ["prefix+x", "alt+esc"]
zoom = "prefix+z"
last_pane = "prefix+;"
focus_pane_left = "ctrl+alt+left"
focus_pane_down = "ctrl+alt+down"
focus_pane_up = "ctrl+alt+up"
focus_pane_right = "ctrl+alt+right"
resize_mode = ["prefix+ctrl+left", "prefix+ctrl+down", "prefix+ctrl+up", "prefix+ctrl+right"]
# Like resize-pane on C-M-S-arrows
resize_pane_left = "ctrl+alt+shift+left"
resize_pane_down = "ctrl+alt+shift+down"
resize_pane_up = "ctrl+alt+shift+up"
resize_pane_right = "ctrl+alt+shift+right"
# No tmux equivalent; herdr's default prefix+shift+p is taken by previous session
rename_pane = "prefix+shift+o"
# Windows -> tabs
new_tab = "prefix+c"
rename_tab = "prefix+r"
close_tab = "prefix+k"
switch_tab = ["prefix+1..9", "alt+1..9"]
previous_tab = ["prefix+p", "alt+left"]
next_tab = ["prefix+n", "alt+right"]
# Like swap-window -t -1/+1 on M-S-Left/Right
move_tab_previous = "alt+shift+left"
move_tab_next = "alt+shift+right"
# Sessions -> workspaces
new_workspace = "prefix+shift+c"
rename_workspace = "prefix+shift+r"
close_workspace = "prefix+shift+k"
previous_workspace = ["prefix+shift+p", "alt+up"]
next_workspace = ["prefix+shift+n", "alt+down"]
[ui]
accent = "blue"
# tmux drew single-line dividers between adjacent panes and no outer frame
pane_gaps = false
pane_outer_borders = false
# tmux had no scrollbar column beside its panes
pane_scrollbars = false
# kill-window and kill-session never asked
confirm_close = false
# automatic-rename gave windows a name without prompting
prompt_new_tab_name = false
# set -g mouse on
mouse_capture = true
# status-right had the zoom flag followed by #h
tab_bar_right = [{ type = "zoom" }, { type = "hostname" }]
# set -g set-titles on / set -g set-titles-string '#h:#W', where tmux's #W was
# the basename of the pane cwd. This is what Hyprland shows in the group bar,
# and it resolves on the server so remote sessions name the remote host.
window_title = "{hostname}: {workspace}"
@@ -0,0 +1,83 @@
-- Keep only your personal input overrides here. Uncommented settings below
-- replace Omarchy's defaults.
-- Keyboard layout and options.
-- See https://wiki.hypr.land/Configuring/Basics/Variables/#input
-- hl.config({
-- input = {
-- -- Use multiple keyboard layouts and switch between them with Left Alt + Right Alt.
-- kb_layout = "us,dk,eu",
-- kb_options = "compose:caps,shift:both_capslock_cancel,grp:alts_toggle",
--
-- -- Use a specific keyboard variant if needed (e.g. intl for international keyboards).
-- kb_variant = "intl",
--
-- -- Change speed of keyboard repeat.
-- repeat_rate = 40,
-- repeat_delay = 250,
--
-- -- Start with numlock on by default.
-- numlock_by_default = true,
--
-- -- Increase sensitivity for mouse/trackpad (default: 0).
-- sensitivity = 0.35,
--
-- -- Turn off mouse acceleration (default: adaptive).
-- accel_profile = "flat",
--
-- touchpad = {
-- -- Use traditional (non-inverse) scrolling.
-- natural_scroll = false,
--
-- -- Re-enable tap-to-click (one-finger tap = left, two-finger = right).
-- tap_to_click = true,
--
-- -- Use two-finger clicks for right-click instead of lower-right corner.
-- clickfinger_behavior = true,
--
-- -- Control the speed of your scrolling.
-- scroll_factor = 0.4,
--
-- -- Enable the touchpad while typing.
-- disable_while_typing = false,
--
-- -- Left-click-and-drag with three fingers.
-- drag_3fg = 1,
-- },
-- },
-- })
-- App-specific touchpad scroll speeds.
-- o.window("(Alacritty|kitty|foot)", { scroll_touchpad = 1.5 })
-- o.window("com.mitchellh.ghostty", { scroll_touchpad = 0.2 })
-- Enable touchpad gestures for changing workspaces.
-- See https://wiki.hypr.land/Configuring/Advanced-and-Cool/Gestures/
-- hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" })
-- Enable touchpad gestures for moving focus (helpful on scrolling layout).
-- hl.gesture({ fingers = 3, direction = "left", action = function() hl.dispatch(hl.dsp.focus({ direction = "l" })) end })
-- hl.gesture({ fingers = 3, direction = "right", action = function() hl.dispatch(hl.dsp.focus({ direction = "r" })) end })
-- lan-mouse (handoff) virtual pointer: DO NOT add sensitivity/accel_profile rules here.
--
-- Tried and measured as INERT on 2026-10-10. `hl.device({name="unknown-device", ...})` does
-- match the device (proven by toggling scroll_factor and watching hyprctl report it), but
-- `sensitivity` and `accel_profile` are libinput pointer-acceleration settings and a
-- wlr-virtual-pointer is not a libinput device, so they have nothing to act on. Measured
-- travel with `lan-mouse test-emulation` + hyprctl cursorpos was 398x198px at sensitivity 0
-- AND at -0.9 -- identical, and exactly 1:1 with the raw deltas. Both machines measure the
-- same 398x198, so neither end scales.
--
-- Corollary: global `accel_profile = "flat"` would also be a no-op for the virtual pointer
-- while permanently changing how the real trackpad feels. Pure loss -- do not do it.
-- The magnitude of forwarded motion originates on the SENDING machine's trackpad.
--
-- ROOT CAUSE FOUND 2026-10-10: lan-mouse 0.11.0 forwards UNACCELERATED deltas.
-- input-capture/src/layer_shell.rs:887 destructures the relative-pointer event as
-- `dx_unaccel: dx, dy_unaccel: dy, ..` -- the `..` throws away the accelerated dx/dy that the
-- same event carries. So the accel curve is stripped at the SENDER and nothing replaces it at
-- the receiver. Consequence: sensitivity/accel_profile are inert for forwarded motion on BOTH
-- ends, so "sync the sensitivity between the laptops" is impossible by configuration.
-- The only fix is a source patch to use the accelerated dx/dy.
-- See memory: lan-mouse-forwards-unaccelerated-deltas.md
@@ -0,0 +1,22 @@
-- See https://wiki.hypr.land/Configuring/Basics/Monitors/
-- List current monitors and supported resolutions with: hyprctl monitors all
-- Monitor scale is Hyprland's scale for the output. It sizes everything
-- Wayland-native, accepts fractions (1.6, 1.75), and applies immediately.
-- "auto" lets Hyprland pick per display.
local omarchy_monitor_scale = 1.33333
hl.monitor({ output = "", mode = "preferred", position = "auto", scale = omarchy_monitor_scale })
-- Configure a specific monitor.
-- hl.monitor({ output = "DP-2", mode = "2560x1440@144", position = "0x0", scale = 1 })
-- Portrait/rotated secondary monitor (transform: 1 = 90°, 3 = 270°).
-- hl.monitor({ output = "DP-2", mode = "preferred", position = "auto", scale = 1, transform = 1 })
-- GDK scale is GDK_SCALE, the factor GTK draws its own UI at. It's what
-- sizes X11/XWayland windows, which Omarchy leaves unscaled so they stay
-- crisp instead of being stretched by the compositor. GTK only honors whole
-- numbers, so use the nearest integer to the monitor scale, and restart an
-- app for a change to reach it.
local omarchy_gdk_scale = 1
hl.env("GDK_SCALE", tostring(omarchy_gdk_scale))
@@ -0,0 +1,5 @@
{
"$schema": "https://opencode.ai/config.json",
"theme": "system",
"autoupdate": false
}
@@ -0,0 +1,100 @@
# Voxtype Configuration
#
# Location: ~/.config/voxtype/config.toml
# All settings can be overridden via CLI flags
#
# State file for external integrations (Waybar, polybar, etc.)
# Use "auto" for default location ($XDG_RUNTIME_DIR/voxtype/state),
# a custom path, or "disabled" to turn off. The daemon writes state
# ("idle", "recording", "transcribing") to this file whenever it changes.
# Required for `voxtype record toggle` and `voxtype status` commands.
state_file = "auto"
[hotkey]
# Hotkey is configured in Hyprland. Default is Super + Ctrl + X
enabled = false
[audio]
# Audio input device ("default" uses system default)
# List devices with: pactl list sources short
device = "default"
# Sample rate in Hz (whisper expects 16000)
sample_rate = 16000
# Maximum recording duration in seconds (safety limit)
max_duration_secs = 60
# Pause MPRIS media players during recording
pause_media = true
# [audio.feedback]
# Enable audio feedback sounds (beeps when recording starts/stops)
# enabled = true
#
# Sound theme: "default", "subtle", "mechanical", or path to custom theme directory
# theme = "default"
#
# Volume level (0.0 to 1.0)
# volume = 0.7
[whisper]
# Model to use for transcription
# Options: tiny, tiny.en, base, base.en, small, small.en, medium, medium.en, large-v3, large-v3-turbo
# .en models are English-only but faster and more accurate for English
# large-v3-turbo is faster than large-v3 with minimal accuracy loss (recommended for GPU)
# Or provide absolute path to a custom .bin model file
model = "base.en"
# Language for transcription
# Use "en" for English, "auto" for auto-detection
# See: https://github.com/openai/whisper#available-models-and-languages
language = "en"
# Translate non-English speech to English
translate = false
# Number of CPU threads for inference (omit for auto-detection)
# threads = 4
[output]
# Primary output mode: "type" or "clipboard"
# - type: Simulates keyboard input at cursor position (requires ydotool)
# - clipboard: Copies text to clipboard (requires wl-copy)
mode = "type"
# Fall back to clipboard if typing fails
fallback_to_clipboard = true
# Delay between typed characters in milliseconds
# 0 = fastest possible, increase if characters are dropped
type_delay_ms = 1
# Post-processing command (optional)
# Pipe transcribed text through an external command for cleanup before output.
# The command receives text on stdin and outputs processed text on stdout.
# Useful for LLM-based text cleanup, grammar correction, filler word removal.
# On any failure (timeout, error), falls back to original transcription.
#
# [output.post_process]
# command = "ollama run llama3.2:1b 'Clean up this dictation. Fix grammar, remove filler words. Output only the cleaned text:'"
# timeout_ms = 30000 # 30 second timeout (generous for LLM)
[output.notification]
# Show notification when recording starts (hotkey pressed)
on_recording_start = false
# Show notification when recording stops (transcription beginning)
on_recording_stop = false
# Show notification with transcribed text after transcription completes
on_transcription = false
# [text]
# Text processing options (word replacements, spoken punctuation)
#
# Enable spoken punctuation conversion (e.g., say "period" to get ".")
# spoken_punctuation = false
#
# Custom word replacements (case-insensitive)
# replacements = { "hyperwhisper" = "hyprwhspr" }
@@ -0,0 +1,35 @@
# MacBook Air M2 packages beyond the Omarchy baseline and the other layers.
# See machines/hp-laptop/packages.txt for the format.
[repo]
# --- Asahi platform: installed by the Asahi installer, listed so a check catches drift ---
linux-asahi
asahi-meta
asahi-audio
asahi-scripts
asahi-fwextract
m1n1
uboot-asahi
speakersafetyd # speaker protection; without it the amps are capped
vulkan-asahi
libva-v4l2_request-avd # hardware video decode (omarchy-aarch64)
pipewire-pulse
pipewire-alsa
# --- access ---
openssh # sshd is enabled: the HP reaches it as `ssh air`
# --- apps ---
obsidian-appimage # omarchy-aarch64
wf-recorder # screen recording
[optional]
steam # asahi-alarm build, runs x86 games through FEX
omarchy-steam-fex
[ignore]
# Arch Linux ARM base system and keyrings
base base-devel sudo omarchy linux-firmware btrfs-progs grub dhcpcd netctl net-tools nano which
ex-vi-compat terminus-font archlinuxarm-keyring asahi-alarm-keyring hyprtoolkit
neovim # Omarchy ships nvim; this is the ALARM name for it
dotnet-runtime-2.1 # explicit, required by nothing; unclear why it is here
+11
View File
@@ -0,0 +1,11 @@
# MacBook Air M2 (omarchy-air): Asahi Linux on Arch Linux ARM, aarch64.
# Captured as it is on 2026-10-16 (Omarchy 4.0.3rc4): no hand-built themes, no
# shell plugins, stock bar, stock terminal fonts. Add personal-desktop to
# include= to give it the HP's desktop.
description="MacBook Air M2 (omarchy-air): Asahi, aarch64"
include=(base personal features/handoff roles/developer roles/devops roles/ai-agents)
hostnames=(omarchy-air)
arch=aarch64
# The peer for omarchy-handoff is the HP: Wi-Fi .24 and the dock NIC .86, which
# disappears on undock, so both.
handoff_peer_ips=(192.168.1.24 192.168.1.86)
@@ -0,0 +1 @@
options appledrm show_notch=1
@@ -0,0 +1 @@
options hid_apple fnmode=1
@@ -0,0 +1,6 @@
# omarchy-air only. Multiplier for the residual logical-ppi difference between
# the two screens (handoff/patch adds it; default 1.0 = upstream). The Air is
# 140.1 logical ppi against ~95.8 for the 23" LG, so 0.7 makes forwarded motion
# cover the same PHYSICAL distance.
[Service]
Environment=LAN_MOUSE_POINTER_SCALE=0.7
@@ -0,0 +1,11 @@
-- personal-desktop layer: the bindings that go with this layer's plugins.
-- Open Claude instead of the stock ChatGPT webapp.
hl.unbind("SUPER + SHIFT + A")
o.bind("SUPER + SHIFT + A", "Claude", { webapp = "https://claude.ai" })
-- Herdr HUD overlay (indie.herdr-hud native plugin, local branch).
o.bind("SUPER + H", "Toggle Herdr HUD", "omarchy-shell shell toggle indie.herdr-hud '{}'")
o.bind("SUPER + ALT + H", "Show or hide Herdr HUD button", "omarchy-shell shell call indie.herdr-hud toggleVisibility '{}'")
o.bind("SUPER + CTRL + DOWN", "Herdr HUD: next agent", "omarchy-shell shell call indie.herdr-hud nextAgent '{}'")
o.bind("SUPER + CTRL + UP", "Herdr HUD: previous agent", "omarchy-shell shell call indie.herdr-hud previousAgent '{}'")
@@ -16,6 +16,9 @@
{ {
"id": "omarchy.workspaces" "id": "omarchy.workspaces"
}, },
{
"id": "niccoconnor.mantel"
},
{ {
"id": "omarchy.active-window" "id": "omarchy.active-window"
} }
@@ -24,15 +27,15 @@
{ {
"id": "omarchy.indicators" "id": "omarchy.indicators"
}, },
{
"id": "omarchy.keyboard-layout"
},
{ {
"id": "omarchy.clock", "id": "omarchy.clock",
"format": "dddd HH:mm", "format": "dddd HH:mm",
"formatAlt": "d MMMM 'W'ww yyyy", "formatAlt": "d MMMM 'W'ww yyyy",
"verticalFormat": "HH\n—\nmm" "verticalFormat": "HH\n—\nmm"
}, },
{
"id": "omarchy.keyboard-layout"
},
{ {
"id": "omarchy.weather" "id": "omarchy.weather"
}, },
@@ -53,6 +56,16 @@
{ {
"id": "omarchy.tray" "id": "omarchy.tray"
}, },
{
"id": "izeesoft.omarchy-phone"
},
{
"id": "omarchy.spacer"
},
{
"id": "io.github.juancasanueva.plugin-manager",
"tiledExpandedPanel": true
},
{ {
"id": "io.github.thisisgm.omapods" "id": "io.github.thisisgm.omapods"
}, },
@@ -89,6 +102,9 @@
"plugins": [ "plugins": [
{ {
"id": "indie.herdr-hud" "id": "indie.herdr-hud"
},
{
"id": "niccoconnor.keyhints"
} }
] ]
} }
+10
View File
@@ -0,0 +1,10 @@
# Packages the plugins in plugins.txt need. See machines/hp-laptop/packages.txt for the format.
[repo]
qmltermwidget # embedded terminal widget; indie.herdr-hud needs it
qt6-connectivity # Bluetooth/QML bits for the omapods plugin
qt6-tools # qmllint etc. when editing plugin QML
openbsd-netcat # nc for kb2uka.port-doctor checks
[aur]
hyprmoncfg-bin # display profile daemon; pairs with crmne.hyprmoncfg plugin
netbird-bin # NetBird client for the covert.vpn private network; pairs with vstoms.netbird
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Show a Herdr pane in the herdr web ui (devswha.herdr-web-ui) as one floating app window.
The shell cannot host a web view (QtWebEngine aborts inside Quickshell), so the HUD
hands agents off to a browser app window instead. The HUD keeps a single window:
asking for the pane it already shows brings it forward; asking for another pane
replaces it in the same place and size. Failures go to a desktop notification,
since the HUD has closed by then.
"""
import json
import os
import subprocess
import sys
import time
from pathlib import Path
from urllib.parse import quote, urlsplit
HOME = Path.home()
WEB_ENV = HOME / ".config/herdr/plugins/config/devswha.herdr-web-ui/env"
CONFIG = HOME / ".config/herdr-hud-native/web.json"
STATE = HOME / ".config/herdr-hud-native/web-window.json"
WAIT_SECONDS = 10
class WebError(RuntimeError):
pass
def base_url() -> str:
"""HERDR_HUD_WEB_URL, else {"url": ...} in web.json, else the web ui's own listen address."""
override = os.environ.get("HERDR_HUD_WEB_URL", "").strip()
if not override:
try:
override = str(json.loads(CONFIG.read_text()).get("url") or "").strip()
except (OSError, ValueError, AttributeError):
override = ""
if override:
return override.rstrip("/")
values = {}
try:
for line in WEB_ENV.read_text().splitlines():
key, sep, value = line.partition("=")
if sep and key.strip() in ("HOST", "PORT"):
values[key.strip()] = value.strip().strip("'\"")
except OSError:
pass
host = values.get("HOST") or "127.0.0.1"
if host in ("0.0.0.0", "::", "[::]"):
host = "127.0.0.1"
return f"http://{host}:{values.get('PORT') or '7317'}"
def hyprctl(*args: str) -> str:
done = subprocess.run(["hyprctl", *args], capture_output=True, text=True, timeout=5)
if done.returncode != 0 or done.stdout.startswith("Couldn't connect"):
raise WebError(done.stdout.strip() or done.stderr.strip() or "hyprctl failed")
return done.stdout
def fix_hyprland_signature() -> None:
"""A shell that outlived a Hyprland restart keeps a dead signature; use the live one."""
try:
hyprctl("version")
return
except WebError:
pass
runtime = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "hypr"
for candidate in sorted(runtime.glob("*/.socket.sock"), key=lambda p: p.stat().st_mtime, reverse=True):
os.environ["HYPRLAND_INSTANCE_SIGNATURE"] = candidate.parent.name
try:
hyprctl("version")
return
except WebError:
continue
raise WebError("Cannot reach Hyprland")
def dispatch(expression: str) -> None:
out = hyprctl("dispatch", expression).strip()
if out != "ok":
raise WebError(out)
def clients() -> list:
return json.loads(hyprctl("clients", "-j"))
def web_windows(host: str) -> dict:
# Chromium-family app windows are classed "<browser>-<host>__<path>-<profile>".
marker = f"-{host}__"
return {c["address"]: c for c in clients() if marker in str(c.get("class", ""))}
def load_state() -> dict:
try:
state = json.loads(STATE.read_text())
return state if isinstance(state, dict) else {}
except (OSError, ValueError):
return {}
def save_state(state: dict) -> None:
STATE.parent.mkdir(parents=True, exist_ok=True)
tmp = STATE.with_suffix(".tmp")
tmp.write_text(json.dumps(state, indent=2) + "\n")
tmp.replace(STATE)
def active_workspace() -> str:
return str(json.loads(hyprctl("activeworkspace", "-j")).get("id", ""))
def bring_forward(address: str) -> None:
window = f"address:{address}"
workspace = active_workspace()
if workspace:
dispatch(f"hl.dsp.window.move({{ workspace = '{workspace}', window = '{window}' }})")
dispatch(f"hl.dsp.focus({{ window = '{window}' }})")
def default_size() -> tuple:
for monitor in json.loads(hyprctl("monitors", "-j")):
if monitor.get("focused"):
scale = float(monitor.get("scale") or 1)
width, height = monitor["width"] / scale, monitor["height"] / scale
return round(width * 0.72), round(height * 0.82)
return 1280, 860
def place(address: str, previous: dict | None) -> None:
window = f"address:{address}"
dispatch(f"hl.dsp.window.float({{ action = 'enable', window = '{window}' }})")
if previous and previous.get("floating"):
width, height = previous["size"]
x, y = previous["at"]
dispatch(f"hl.dsp.window.resize({{ x = {width}, y = {height}, window = '{window}' }})")
dispatch(f"hl.dsp.window.move({{ x = {x}, y = {y}, window = '{window}' }})")
else:
width, height = default_size()
dispatch(f"hl.dsp.window.resize({{ x = {width}, y = {height}, window = '{window}' }})")
dispatch(f"hl.dsp.window.center({{ window = '{window}' }})")
dispatch(f"hl.dsp.focus({{ window = '{window}' }})")
def open_pane(pane: str) -> None:
base = base_url()
host = urlsplit(base).hostname or ""
if not host:
raise WebError(f"Bad web ui address: {base}")
fix_hyprland_signature()
existing = web_windows(host)
state = load_state()
tracked = existing.get(str(state.get("address", "")))
if tracked and state.get("pane") == pane:
bring_forward(tracked["address"])
return
if tracked:
dispatch(f"hl.dsp.window.close({{ window = 'address:{tracked['address']}' }})")
url = f"{base}/?pane={quote(pane, safe='')}" if pane else f"{base}/"
subprocess.Popen(["omarchy-launch-webapp", url], stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
deadline = time.monotonic() + WAIT_SECONDS
while time.monotonic() < deadline:
time.sleep(0.15)
fresh = [a for a in web_windows(host) if a not in existing]
if fresh:
place(fresh[0], tracked)
save_state({"address": fresh[0], "pane": pane})
return
raise WebError("The web ui window did not appear")
def main() -> int:
if len(sys.argv) != 3 or sys.argv[1] != "open":
print("usage: herdr-web open PANE", file=sys.stderr)
return 2
try:
open_pane(sys.argv[2])
except (WebError, OSError, ValueError, KeyError, subprocess.TimeoutExpired) as error:
subprocess.run(["notify-send", "Herdr HUD", f"Cannot open the web ui: {error}"], check=False)
print(error, file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -1,5 +1,5 @@
diff --git i/HerdrHud.qml w/HerdrHud.qml diff --git i/HerdrHud.qml w/HerdrHud.qml
index 018311d..d01449c 100644 index 018311d..4a73ee0 100644
--- i/HerdrHud.qml --- i/HerdrHud.qml
+++ w/HerdrHud.qml +++ w/HerdrHud.qml
@@ -7,6 +7,7 @@ import Quickshell.Wayland @@ -7,6 +7,7 @@ import Quickshell.Wayland
@@ -10,11 +10,12 @@ index 018311d..d01449c 100644
import "." import "."
Item { Item {
@@ -21,12 +22,13 @@ Item { @@ -21,12 +22,14 @@ Item {
readonly property string pluginId: (manifest && manifest.id) || "indie.herdr-hud" readonly property string pluginId: (manifest && manifest.id) || "indie.herdr-hud"
readonly property string pluginDir: (manifest && manifest.__sourceDir) || "" readonly property string pluginDir: (manifest && manifest.__sourceDir) || ""
readonly property string bridgePath: decodeURIComponent(String(Qt.resolvedUrl("bin/herdr-monitor")).replace(/^file:\/\//, "")) readonly property string bridgePath: decodeURIComponent(String(Qt.resolvedUrl("bin/herdr-monitor")).replace(/^file:\/\//, ""))
+ readonly property string agentsPath: decodeURIComponent(String(Qt.resolvedUrl("bin/herdr-agents")).replace(/^file:\/\//, "")) + readonly property string agentsPath: decodeURIComponent(String(Qt.resolvedUrl("bin/herdr-agents")).replace(/^file:\/\//, ""))
+ readonly property string webPath: decodeURIComponent(String(Qt.resolvedUrl("bin/herdr-web")).replace(/^file:\/\//, ""))
readonly property string homeDir: Quickshell.env("HOME") readonly property string homeDir: Quickshell.env("HOME")
readonly property string configDir: homeDir + "/.config/herdr-hud-native" readonly property string configDir: homeDir + "/.config/herdr-hud-native"
readonly property string statePath: configDir + "/state.json" readonly property string statePath: configDir + "/state.json"
@@ -25,7 +26,7 @@ index 018311d..d01449c 100644
property bool overlayVisible: true property bool overlayVisible: true
property bool openingRequested: false property bool openingRequested: false
property bool demoMode: false property bool demoMode: false
@@ -35,10 +37,136 @@ Item { @@ -35,10 +38,136 @@ Item {
property var agents: [] property var agents: []
property string selectedPane: "" property string selectedPane: ""
property var unread: ({}) property var unread: ({})
@@ -166,7 +167,25 @@ index 018311d..d01449c 100644
property var lastSequence: ({}) property var lastSequence: ({})
property var alertQueue: [] property var alertQueue: []
property bool alertsEnabled: true property bool alertsEnabled: true
@@ -190,6 +318,9 @@ Item { @@ -66,6 +195,10 @@ Item {
property bool stateReady: false
property string uiMode: "omarchy"
+ // "web" hands agents to the herdr web ui in a browser app window; "terminal" attaches
+ // the native terminal in the panel. The shell can't host a web view itself.
+ property string viewMode: "web"
+ readonly property bool webView: viewMode === "web"
readonly property bool wowMode: uiMode === "wow"
readonly property int cornerRadius: wowMode ? 4 : 0
readonly property string chromeFont: wowMode ? "Georgia" : Style.font.family
@@ -184,12 +317,16 @@ Item {
if (parsed && typeof parsed === "object") {
if (Number.isInteger(parsed.launcherStyle) && parsed.launcherStyle >= 0 && parsed.launcherStyle < 6) launcherStyle = parsed.launcherStyle
if (parsed.uiMode === "wow" || parsed.uiMode === "omarchy") uiMode = parsed.uiMode
+ if (parsed.viewMode === "web" || parsed.viewMode === "terminal") viewMode = parsed.viewMode
if (typeof parsed.alertsEnabled === "boolean") alertsEnabled = parsed.alertsEnabled
if (typeof parsed.overlayVisible === "boolean") overlayVisible = parsed.overlayVisible
if (parsed.positions && typeof parsed.positions === "object") positions = parsed.positions
if (Number(parsed.rosterWidth) > 0) rosterWidth = clamp(Number(parsed.rosterWidth), 150, 330) if (Number(parsed.rosterWidth) > 0) rosterWidth = clamp(Number(parsed.rosterWidth), 150, 330)
if (Number(parsed.panelWidth) > 0) panelWidth = Math.max(minPanelWidth, Number(parsed.panelWidth)) if (Number(parsed.panelWidth) > 0) panelWidth = Math.max(minPanelWidth, Number(parsed.panelWidth))
if (Number(parsed.panelHeight) > 0) panelHeight = Math.max(minPanelHeight, Number(parsed.panelHeight)) if (Number(parsed.panelHeight) > 0) panelHeight = Math.max(minPanelHeight, Number(parsed.panelHeight))
@@ -176,7 +195,14 @@ index 018311d..d01449c 100644
} }
} catch (error) { } catch (error) {
positions = ({}) positions = ({})
@@ -209,7 +340,10 @@ Item { @@ -203,13 +340,17 @@ Item {
stateFile.setText(JSON.stringify({
version: 1,
uiMode: uiMode,
+ viewMode: viewMode,
alertsEnabled: alertsEnabled,
launcherStyle: launcherStyle,
overlayVisible: overlayVisible,
positions: positions, positions: positions,
rosterWidth: Math.round(rosterWidth), rosterWidth: Math.round(rosterWidth),
panelWidth: Math.round(panelWidth), panelWidth: Math.round(panelWidth),
@@ -188,7 +214,32 @@ index 018311d..d01449c 100644
}, null, 2) + "\n") }, null, 2) + "\n")
} }
@@ -486,9 +620,10 @@ Item { @@ -367,6 +508,24 @@ Item {
})
}
+ function toggleViewMode() {
+ viewMode = webView ? "terminal" : "web"
+ saveState()
+ }
+
+ // Shows the agent in the herdr web ui and closes the panel, which would cover it.
+ function openInWeb(pane) {
+ if (demoMode || !pane) return
+ Quickshell.execDetached(["python3", webPath, "open", pane])
+ requestClose()
+ }
+
+ // A card click: in web view it also opens the agent there.
+ function activateAgent(pane) {
+ selectAgent(pane)
+ if (webView) openInWeb(pane)
+ }
+
// Keyboard agent switching, in the roster's displayed order.
function selectRelative(step) {
var list = sortedAgents
@@ -486,9 +645,10 @@ Item {
return muted return muted
} }
@@ -202,7 +253,17 @@ index 018311d..d01449c 100644
return type.charAt(0).toUpperCase() + type.slice(1) return type.charAt(0).toUpperCase() + type.slice(1)
} }
@@ -581,6 +716,7 @@ Item { @@ -553,7 +713,8 @@ Item {
if (!live || live.terminal_id !== agent.terminal_id) { showNextAlert(); return }
clearAlerts()
selectAgent(String(agent.pane_id))
- openOnScreen(screenName)
+ if (webView) openInWeb(String(agent.pane_id))
+ else openOnScreen(screenName)
}
function previewAlert(_arg) {
@@ -581,6 +742,7 @@ Item {
var nextUnread = cloneObject(unread) var nextUnread = cloneObject(unread)
var nextSequence = ({}) var nextSequence = ({})
var nextWorkingSince = ({}) var nextWorkingSince = ({})
@@ -210,7 +271,7 @@ index 018311d..d01449c 100644
var live = ({}) var live = ({})
var previousByPane = ({}) var previousByPane = ({})
for (var p = 0; p < agents.length; p++) previousByPane[String(agents[p].pane_id || "")] = agents[p] for (var p = 0; p < agents.length; p++) previousByPane[String(agents[p].pane_id || "")] = agents[p]
@@ -595,13 +731,20 @@ Item { @@ -595,13 +757,20 @@ Item {
nextWorkingSince[identity] = workingSince[identity] || Date.now() nextWorkingSince[identity] = workingSince[identity] || Date.now()
} }
live[pane] = true live[pane] = true
@@ -231,13 +292,14 @@ index 018311d..d01449c 100644
workingSince = nextWorkingSince workingSince = nextWorkingSince
activityNow = Date.now() activityNow = Date.now()
var currentAgent = agentForPane(selectedPane) var currentAgent = agentForPane(selectedPane)
@@ -616,7 +759,12 @@ Item { @@ -616,7 +785,13 @@ Item {
} }
completed.forEach(function(agent) { root.queueAlert(agent) }) completed.forEach(function(agent) { root.queueAlert(agent) })
errorText = "" errorText = ""
- if (!agentForPane(selectedPane)) selectAgent(rows.length ? String(rows[0].pane_id || "") : "") - if (!agentForPane(selectedPane)) selectAgent(rows.length ? String(rows[0].pane_id || "") : "")
+ if (pendingPane && live[pendingPane]) { + if (pendingPane && live[pendingPane]) {
+ selectAgent(pendingPane) + selectAgent(pendingPane)
+ if (webView && opened) openInWeb(pendingPane)
+ pendingPane = "" + pendingPane = ""
+ } + }
+ if (confirmClosePane && !live[confirmClosePane]) confirmClosePane = "" + if (confirmClosePane && !live[confirmClosePane]) confirmClosePane = ""
@@ -245,7 +307,7 @@ index 018311d..d01449c 100644
dataRevision++ dataRevision++
} catch (parseError) { } catch (parseError) {
alertBaseline = false alertBaseline = false
@@ -627,6 +775,86 @@ Item { @@ -627,6 +802,86 @@ Item {
} }
} }
@@ -332,7 +394,18 @@ index 018311d..d01449c 100644
Component.onCompleted: { Component.onCompleted: {
mkdirProc.running = true mkdirProc.running = true
Qt.callLater(root.refreshRoster) Qt.callLater(root.refreshRoster)
@@ -689,6 +917,21 @@ Item { @@ -637,8 +892,8 @@ Item {
parent: root.viewForScreen(root.panelScreenName)
? root.viewForScreen(root.panelScreenName).terminalContainer : root
anchors.fill: parent
- visible: root.opened && root.overlayVisible
- target: root.opened && root.overlayVisible && !root.demoMode
+ visible: root.opened && root.overlayVisible && !root.webView
+ target: root.opened && root.overlayVisible && !root.demoMode && !root.webView
? String(root.agentForPane(root.selectedPane)?.terminal_id || "") : ""
foreground: root.foreground
muted: root.muted
@@ -689,6 +944,21 @@ Item {
onExited: function(exitCode) { root.applyRoster(rosterOut.text, rosterErr.text, exitCode) } onExited: function(exitCode) { root.applyRoster(rosterOut.text, rosterErr.text, exitCode) }
} }
@@ -354,7 +427,59 @@ index 018311d..d01449c 100644
property string previewIdentity: "" property string previewIdentity: ""
Process { Process {
id: alertPreviewProc id: alertPreviewProc
@@ -1034,6 +1277,186 @@ Item { @@ -786,6 +1056,11 @@ Item {
enabled: overlayWindow.panelVisible
onActivated: root.selectRelative(-1)
}
+ Shortcut {
+ sequences: ["Return", "Enter"]
+ enabled: overlayWindow.panelVisible && root.webView && !root.newAgentOpen && !!root.selectedPane
+ onActivated: root.openInWeb(root.selectedPane)
+ }
Instantiator {
model: 9
delegate: Shortcut {
@@ -991,6 +1266,31 @@ Item {
verticalAlignment: Text.AlignVCenter
}
}
+ Button {
+ id: viewButton
+ text: root.webView ? "View: Web" : "View: Terminal"
+ Layout.preferredWidth: 132
+ Layout.fillHeight: true
+ hoverEnabled: true
+ focusPolicy: Qt.NoFocus
+ onClicked: root.toggleViewMode()
+ background: Rectangle {
+ radius: root.cornerRadius
+ color: viewButton.down ? root.alpha(root.accent, 0.28)
+ : root.alpha(root.accent, viewButton.hovered ? 0.18 : 0.08)
+ border.width: 1
+ border.color: viewButton.hovered ? root.accent : root.alpha(root.accent, 0.5)
+ }
+ contentItem: Text {
+ text: viewButton.text + " ⇄"
+ color: root.accent
+ font.family: root.chromeFont
+ font.pixelSize: 12
+ font.bold: true
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ }
+ }
Item { Layout.fillWidth: true }
Text {
visible: root.wowMode
@@ -1005,6 +1305,7 @@ Item {
}
Button {
id: disconnectTerminalButton
+ visible: !root.webView
text: terminalPane.clientRunning ? "Disconnect" : "Reconnect"
enabled: !!terminalPane.target && !terminalPane.stopping
focusPolicy: Qt.NoFocus
@@ -1034,6 +1335,186 @@ Item {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
@@ -541,7 +666,7 @@ index 018311d..d01449c 100644
RowLayout { RowLayout {
anchors.fill: parent anchors.fill: parent
spacing: 0 spacing: 0
@@ -1051,15 +1474,36 @@ Item { @@ -1051,15 +1532,36 @@ Item {
anchors.margins: 8 anchors.margins: 8
spacing: 8 spacing: 8
@@ -586,7 +711,7 @@ index 018311d..d01449c 100644
} }
ListView { ListView {
@@ -1093,12 +1537,16 @@ Item { @@ -1093,12 +1595,16 @@ Item {
z: 1 z: 1
} }
@@ -598,13 +723,13 @@ index 018311d..d01449c 100644
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
- onClicked: root.selectAgent(String(agentRow.modelData.pane_id || "")) - onClicked: root.selectAgent(String(agentRow.modelData.pane_id || ""))
+ onClicked: root.selectAgent(agentRow.pane) + onClicked: root.activateAgent(agentRow.pane)
} }
+ HoverHandler { id: rowHover } + HoverHandler { id: rowHover }
RowLayout { RowLayout {
anchors.fill: parent anchors.fill: parent
@@ -1123,7 +1571,7 @@ Item { @@ -1123,7 +1629,7 @@ Item {
Text { Text {
Layout.fillWidth: true Layout.fillWidth: true
@@ -613,7 +738,7 @@ index 018311d..d01449c 100644
color: root.accent color: root.accent
font.family: root.chromeFont font.family: root.chromeFont
font.pixelSize: 15 font.pixelSize: 15
@@ -1132,32 +1580,59 @@ Item { @@ -1132,32 +1638,59 @@ Item {
} }
Text { Text {
Layout.fillWidth: true Layout.fillWidth: true
@@ -682,11 +807,71 @@ index 018311d..d01449c 100644
Layout.alignment: Qt.AlignTop Layout.alignment: Qt.AlignTop
Layout.topMargin: 2 Layout.topMargin: 2
text: String(agentRow.index + 1) text: String(agentRow.index + 1)
@@ -1269,6 +1802,46 @@ Item {
id: terminalSlot
anchors.fill: parent
anchors.margins: 6
+
+ Column {
+ id: webPrompt
+ anchors.centerIn: parent
+ width: Math.min(parent.width - 32, 360)
+ spacing: 12
+ visible: root.webView
+ readonly property var agent: root.agentForPane(root.selectedPane)
+ Text {
+ width: parent.width
+ text: webPrompt.agent ? root.agentName(webPrompt.agent) : "No agent selected"
+ color: root.foreground
+ font.family: root.chromeFont
+ font.pixelSize: 16
+ font.bold: true
+ horizontalAlignment: Text.AlignHCenter
+ elide: Text.ElideRight
+ }
+ Text {
+ width: parent.width
+ text: "Click an agent, or press Enter, to open it in the herdr web ui."
+ color: root.muted
+ font.family: root.chromeFont
+ font.pixelSize: 12
+ wrapMode: Text.Wrap
+ horizontalAlignment: Text.AlignHCenter
+ }
+ Row {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: 8
+ HudChip {
+ hud: root
+ text: "Open in web ui"
+ primary: true
+ enabled: !!webPrompt.agent && !root.demoMode
+ onClicked: root.openInWeb(root.selectedPane)
+ }
+ HudChip { hud: root; text: "Use native terminal"; onClicked: root.toggleViewMode() }
+ }
+ }
}
}
diff --git i/README.md w/README.md diff --git i/README.md w/README.md
index 685096b..37df3e5 100644 index 685096b..cb5b0b9 100644
--- i/README.md --- i/README.md
+++ w/README.md +++ w/README.md
@@ -44,6 +44,14 @@ rm -rf ~/.config/herdr-hud-native @@ -38,12 +38,27 @@ rm -rf ~/.config/herdr-hud-native
## Features
+- **View: Web** (default) opens the clicked agent, or an alert's agent, in the
+ [herdr web ui](https://github.com/devswha/herdr-web-ui) as one floating browser app window
+ (`bin/herdr-web`). Picking another agent replaces it in the same place and size; the panel
+ closes so it doesn't cover it. The address comes from `~/.config/herdr-hud-native/web.json`
+ (`{"url": "https://…"}`), else the web ui's own `HOST`/`PORT`. Quickshell can't host a web
+ view (QtWebEngine aborts in the shell), hence the separate window. **View: Terminal** switches
+ back to the in-panel terminal
- Native VT terminal attached to the selected Herdr agent
- Shift+drag selects terminal text; releasing the mouse copies it to the clipboard and clears the highlight
- Ctrl+Shift+V or Shift+Insert pastes the clipboard into the focused terminal; Ctrl+V remains available to terminal applications
- Approval-dialog keyboard input and TUI rendering - Approval-dialog keyboard input and TUI rendering
- Omarchy and WoW designs - Omarchy and WoW designs
- Agent switching, launcher, alerts, hidden-panel notifications - Agent switching, launcher, alerts, hidden-panel notifications
@@ -4,9 +4,10 @@ akshar.radio-atlas https://github.com/AksharP5/omarchy-radio-atlas.git ffa7186b5
crmne.hyprmoncfg https://github.com/crmne/omarchy-hyprmoncfg.git 193e5611f934eec42460ef75f3f46c2a8bd0252a crmne.hyprmoncfg https://github.com/crmne/omarchy-hyprmoncfg.git 193e5611f934eec42460ef75f3f46c2a8bd0252a
ilyazar.btop https://github.com/omarchy-QOL/omarchy-btop-activity.git 1e896f6f37bf880dee3e55e36ff04f967745c527 ilyazar.btop https://github.com/omarchy-QOL/omarchy-btop-activity.git 1e896f6f37bf880dee3e55e36ff04f967745c527
indie.herdr-hud https://github.com/kt3v/omarchy-herdr-hud-native.git 75bef0f8012304a4edfec90c690b9f45fe65a04e indie.herdr-hud https://github.com/kt3v/omarchy-herdr-hud-native.git 75bef0f8012304a4edfec90c690b9f45fe65a04e
io.github.tallsam.navbar-cat https://github.com/tallsam/omarchy-navbar-cat.git 6ddd61a6744bab19b04d131ff4f2e087a504ce20
io.github.thisisgm.omapods https://github.com/thisisgm/omarchy-pods fff7fec600a5b9a61cdb40e93eccbcceb4b8f824 io.github.thisisgm.omapods https://github.com/thisisgm/omarchy-pods fff7fec600a5b9a61cdb40e93eccbcceb4b8f824
kb2uka.port-doctor https://github.com/Kb2uka/omarchy-port-doctor.git baa13451bfc27d59e8c831c217852279a20901aa kb2uka.port-doctor https://github.com/Kb2uka/omarchy-port-doctor.git baa13451bfc27d59e8c831c217852279a20901aa
kokd.remote-desktop https://github.com/kostadinkadiev/omarchy-remote-desktop.git 7913b89521f88fac40620f495d1e07835bbb55b4 kokd.remote-desktop https://github.com/kostadinkadiev/omarchy-remote-desktop.git 7913b89521f88fac40620f495d1e07835bbb55b4
omaplug https://github.com/fross100/omaplug.git 9da73fc13fc2027be5b44915b4ab89440beb74ab omaplug https://github.com/fross100/omaplug.git 9da73fc13fc2027be5b44915b4ab89440beb74ab
vstoms.netbird https://github.com/vstoms/omarchy-netbird.git d530c76b13324b26dafd79934529b4395e18a11c vstoms.netbird https://github.com/vstoms/omarchy-netbird.git d530c76b13324b26dafd79934529b4395e18a11c
io.github.juancasanueva.plugin-manager https://github.com/juancasanueva/omarchy-plugin-manager.git 0d26a81b528ecb6e2d5580192b20f53d793009fc
izeesoft.omarchy-phone https://github.com/AdamMusa/omarchy-phone.git 5b4d275b555abb9e3fed3100ee14af5e83fc0a82
+6
View File
@@ -0,0 +1,6 @@
# The full personal desktop: hand-built themes, the shell plugins (pinned, with
# local plugin work replayed from plugin-patches/), the bar layout and the
# bindings that go with them. Only the HP laptop carries this today.
description="Nic's desktop: themes, shell plugins, bar layout, Claude/Herdr bindings"
# Themes managed by aether (reinstall with `aether`), so not carried here.
aether_themes=(pink-gt3rs porsche-cayman porsche-rain)

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

Before

Width:  |  Height:  |  Size: 3.0 MiB

After

Width:  |  Height:  |  Size: 3.0 MiB

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Before

Width:  |  Height:  |  Size: 326 KiB

After

Width:  |  Height:  |  Size: 326 KiB

Before

Width:  |  Height:  |  Size: 324 KiB

After

Width:  |  Height:  |  Size: 324 KiB

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Before

Width:  |  Height:  |  Size: 450 KiB

After

Width:  |  Height:  |  Size: 450 KiB

+3
View File
@@ -0,0 +1,3 @@
# See machines/hp-laptop/packages.txt for the format.
[repo]
voxtype-bin # push-to-talk voice-to-text (voxtype.service); in the omarchy repo on both arches
+3
View File
@@ -0,0 +1,3 @@
# What both of Nic's laptops share today: branding, default agent, herdr,
# opencode and dictation. Not for Coder desktops, which other people use.
description="Nic's identity: branding, default agent, herdr, opencode, voxtype"
+7
View File
@@ -0,0 +1,7 @@
# See machines/hp-laptop/packages.txt for the format.
[repo]
claude-code cmd=claude # omarchy repo, both arches
github-copilot-cli cmd=copilot # omarchy repo, both arches
gemini-cli cmd=gemini
opencode x86_64 cmd=opencode # not in Arch Linux ARM; dotfiles installs it there
openai-codex x86_64 cmd=codex # not in Arch Linux ARM; dotfiles installs it there
+9
View File
@@ -0,0 +1,9 @@
# Coding agents. Omarchy already ships herdr (the agent multiplexer) and
# `omarchy agent`, which launches whichever agent `omarchy-default-agent`
# names; this installs the agents themselves. Choosing the default is left to
# the person (Omarchy menu > Setup > Agent), or to a layer that ships
# config/omarchy/defaults/agent, as personal/ does.
description="Coding agents: Claude Code, OpenCode, Codex, Gemini, Copilot"
# mcp-config points opencode at the covert-gpt LiteLLM gateway (needs secrets);
# hermes is the Hermes agent. Neither belongs on a Coder desktop.
dotfiles=(bin mcp-config hermes)
+13
View File
@@ -0,0 +1,13 @@
# See machines/hp-laptop/packages.txt for the format. cmd= skips a package whose
# command is already on PATH, e.g. from dotfiles' ~/.local/bin shims.
[repo]
github-cli cmd=gh # GitHub CLI
git-delta cmd=delta # syntax-highlighted git diffs
uv cmd=uv # Python packaging and venvs
just cmd=just # command runner
direnv cmd=direnv # per-directory env (.envrc)
shfmt cmd=shfmt # shell formatter
shellcheck x86_64 cmd=shellcheck # not built for aarch64 by Arch Linux ARM
httpie cmd=http # HTTP client
mise x86_64 cmd=mise # runtime manager Omarchy's dev-env uses; HP has mise-bin, ALARM has no mise
visual-studio-code-bin cmd=code # editor (omarchy repo)
+8
View File
@@ -0,0 +1,8 @@
# General software development. Omarchy already ships git, docker (+compose,
# buildx), lazygit, lazydocker, nvim, ripgrep, fd, fzf, clang/llvm and
# `omarchy install dev-env <lang>` for language runtimes via mise, so this only
# adds what is missing from that.
description="Software development: gh, delta, uv, just, direnv, VS Code"
# dotfiles modules this role pairs with on Nic's machines (advisory; install.sh
# prints them, it never runs dotfiles itself): zsh, git
dotfiles=(zsh git)
+28
View File
@@ -0,0 +1,28 @@
# See machines/hp-laptop/packages.txt for the format.
[repo]
# --- Kubernetes ---
kubectl cmd=kubectl
helm cmd=helm
k9s cmd=k9s
kubectx cmd=kubectx # kubectx + kubens
stern cmd=stern # multi-pod log tailing
kustomize cmd=kustomize
talosctl cmd=talosctl # the Omni-managed cluster runs Talos
cilium-cli cmd=cilium-cli,cilium
# --- infrastructure as code ---
opentofu cmd=tofu
terragrunt cmd=terragrunt
packer cmd=packer # Proxmox desktop images
ansible cmd=ansible
go-task cmd=go-task,task # Arch names the binary go-task; dotfiles installs it as task
go-yq cmd=yq
sops cmd=sops # encrypted secrets in git
age cmd=age # sops' key backend
# --- network debugging ---
bind cmd=dig # dig, delv, nslookup
mtr cmd=mtr
whois cmd=whois
nmap cmd=nmap
tcpdump cmd=tcpdump
+8
View File
@@ -0,0 +1,8 @@
# Infrastructure and Kubernetes work: the Omni/Talos cluster, OpenTofu,
# Packer/Proxmox images, Ansible.
#
# On Nic's laptops dotfiles' bin/ already puts most of these in ~/.local/bin,
# and each line's cmd= skips the package when that shim exists. On a Coder
# desktop (no dotfiles, no 1Password) the Arch packages are the tools.
description="Infrastructure/Kubernetes: kubectl, helm, k9s, tofu, talosctl, ansible"
dotfiles=(bin ansible secrets)