Merge quattro and preserve per-theme wallpaper memory

This commit is contained in:
OldJobobo committed 2026-09-23 10:41:32 -07:00
commit 0385610cfe
582 files changed
+33732 -3163

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
blank_issues_enabled: false
contact_links:
- name: Suggestion
url: https://github.com/basecamp/omarchy/discussions/categories/suggestions
url: https://github.com/omacom/omarchy/discussions/categories/suggestions
about: Suggest a new feature, change to existing feature, or other ideas in Discussions.
- name: Support
url: https://omarchy.org/discord
+47
View File
@@ -0,0 +1,47 @@
# Security at Omarchy
## Report a vulnerability
If you believe you’ve found a security vulnerability in Omarchy, please tell the [Omarchy Security Team](https://omarchy.org/teams/#security) privately so we have an opportunity to investigate and fix it before it is made public.
[security@omarchy.org](mailto:security@omarchy.org?subject=Security%20report)
Please don’t report potential vulnerabilities publicly in GitHub Issues, Discord, or social media before they’ve been resolved.
## What is a vulnerability?
We consider a bug a security vulnerability when it can be exploited to cross a meaningful security boundary: an untrusted or lower-privileged party gains access, permissions, or control they didn’t already have.
Code that could be more robust but does not cross a security boundary is an improvement rather than a security vulnerability. We may still merge a proposed fix and credit the reporter in our release notes.
Eligibility for our [security credits](https://omarchy.org/security/credits/) page depends on whether a report identifies a confirmed security vulnerability, not on its severity.
## What to include
Give us enough information to understand and reproduce the issue:
- The affected component and Omarchy version.
- An explanation of what an attacker can do before and after exploitation.
- Steps to reproduce the issue and any proof of concept.
- Your preferred contact details for follow-up.
## Responsible disclosure
Please act in good faith while investigating and reporting vulnerabilities:
- Only test systems and accounts you own or have explicit permission to test.
- Avoid privacy violations, disruption, data destruction, and service degradation.
- Don’t exploit a vulnerability beyond what is needed to demonstrate it.
- Give us a reasonable opportunity to investigate and address the issue before publishing details.
We’ll review your report and keep you informed as we’re able while we work toward a resolution.
## Credits
Researchers who privately report a confirmed security vulnerability and give us the chance to ship a fix are thanked on the [security credits](https://omarchy.org/security/credits/) page. Accepted improvements that don’t cross a security boundary may still be credited in our release notes.
Credits link to each reporter’s X profile and show their avatar. For duplicate reports, only the first reporter is eligible for credit.
## Regular bugs and support
For anything that isn’t a security vulnerability, please use the [Omarchy issue tracker](https://github.com/omacom/omarchy/issues).
+2
View File
@@ -2,3 +2,5 @@
# Python bytecode (orchestrator)
__pycache__/
*.pyc
.claude/
+11 -14
View File
@@ -1,14 +1,12 @@
# Omarchy
Omarchy is a beautiful, modern & opinionated Linux distribution by DHH.
Omarchy is a beautiful, fun & agentic Linux distribution by DHH.
Read more at [omarchy.org](https://omarchy.org).
## The Omarchy Manual
The manual lives in [`manual/`](manual/), which is its authoritative source. It's
mirrored to [learn.omacom.io](https://learn.omacom.io/2/the-omarchy-manual), where
its screenshots are also hosted.
The manual lives in [`manual/`](manual/), which is its authoritative source.
- [Welcome to Omarchy!](manual/01-welcome-to-omarchy.md)
@@ -61,19 +59,18 @@ its screenshots are also hosted.
- [Prompt](manual/40-prompt.md)
- [Branding](manual/41-branding.md)
- [Common tweaks](manual/42-common-tweaks.md)
- [Extra themes](manual/43-extra-themes.md)
- [Making your own theme](manual/44-making-your-own-theme.md)
- [Making your own theme](manual/43-making-your-own-theme.md)
**The Rest**
- [Mac support](manual/45-mac-support.md)
- [Troubleshooting](manual/46-troubleshooting.md)
- [FAQ](manual/47-faq.md)
- [System snapshots](manual/48-system-snapshots.md)
- [Security](manual/49-security.md)
- [Omarchy on...](manual/50-omarchy-on.md)
- [Dual Boot Install](manual/51-dual-boot-install.md)
- [Unattended Installs](manual/52-unattended-installs.md)
- [Mac support](manual/44-mac-support.md)
- [Troubleshooting](manual/45-troubleshooting.md)
- [FAQ](manual/46-faq.md)
- [System snapshots](manual/47-system-snapshots.md)
- [Security](manual/48-security.md)
- [Omarchy on...](manual/49-omarchy-on.md)
- [Dual Boot Install](manual/50-dual-boot-install.md)
- [Unattended Installs](manual/51-unattended-installs.md)
## License
+3 -7
View File
@@ -70,10 +70,6 @@ magick -background white -fill black -font default/fonts/omarchy/omarchy.ttf \
-pointsize 110 label:@/tmp/row.txt /tmp/font-row.png
```
Then confirm it in the running menu per
[`visual-verification.md`](visual-verification.md). Fontconfig prefers the
packaged font over a copy in `~/.local/share/fonts` for the same family, so a
preview needs either the real file replaced or a `<rejectfont>` rule in
`~/.config/fontconfig/conf.d/` pointing fontconfig away from the packaged one.
Restart the shell afterwards — Qt reads the font database at startup, so
`omarchy menu refresh` alone will not pick up a changed font.
Then confirm it in the running menu per [`visual-verification.md`](visual-verification.md). Avoid leaving two fonts with the `omarchy` family registered: Qt can use an old copy in `~/.local/share/fonts` even when `fc-match omarchy` reports the packaged font. For a preview, either replace the packaged file in the disposable VM or temporarily exclude it with a `<rejectfont>` rule in `~/.config/fontconfig/conf.d/` before loading the candidate.
Refresh the font cache and restart the shell afterwards — Qt reads the font database at startup, so `omarchy menu refresh` alone will not pick up a changed font. Remove temporary fonts and rules after verification.
+1
View File
@@ -13,6 +13,7 @@ commands and reusable setup leaves:
- use `$OMARCHY_INSTALL` and `$OMARCHY_PATH` instead of hard-coded Omarchy paths.
- keep root-scoped hardware setup under `install/hardware/` and orchestrate it through `install/hardware/all.sh`.
- keep every per-user setup leaf under `install/user/` (including `install/user/hardware/` and `install/user/first-run/`) so it is clear what must run for each user.
- The base install supplies matching kernel headers before hardware setup. DKMS installers should install their driver packages and assume the headers exist.
- prefer helper commands for package and command checks where available.
Raw `command -v`, `pacman`, and `pacman-key` are acceptable in package-helper
+5
View File
@@ -126,6 +126,7 @@ New migration format:
- Start with an `echo` describing what the migration does.
- Use `$OMARCHY_PATH` to reference the Omarchy directory.
- Be idempotent. Check existing state before changing it.
- Migrations are strictly ordered and synchronous. A migration that cannot finish must exit non-zero, remain pending, and stop the queue; never mark later migrations complete against state an earlier migration has not established.
- Use helper commands such as `omarchy-cmd-present`, `omarchy-cmd-missing`,
`omarchy-pkg-add`, `omarchy-pkg-drop`, `omarchy-pkg-present`, and
`omarchy-pkg-missing` when appropriate.
@@ -162,6 +163,10 @@ rm ~/.local/state/omarchy/migrations/<migration>.sh
omarchy-migrate
```
Keep a dedicated test while the migration is still being written or bugfixed, if it calls an Omarchy helper whose interface can still change, or if it is a security-sensitive privileged repair (FIDO2, leftover installer artifacts, udev, sshd). Once a one-shot rewrite has shipped in a tagged release and is frozen, drop the test even when that rewrite used sudo, pacman, or limine-mkinitcpio. Keep the migration itself for late-updaters. Tests of `omarchy-migrate`, the login notifier, and `omarchy-upgrade-to-quattro` stay.
Omarchy 4.0 is upgraded through `bin/omarchy-upgrade-to-quattro`, not through the
normal migration runner. Do not add compatibility migrations for old installer
layouts; put pre-4 package-layout transition work in the upgrade command instead.
Clearing a privileged file that a retired installer left on disk is the exception, and belongs in a migration whether or not that installer was part of a package layout transition. The upgrade command only runs on a machine still making the 3 to 4 crossing, so anything put there never reaches an install that crossed already, and it never runs at all for an installer that was retired on its own — while the file the installer wrote is still sitting on those machines. The upgrade command finishes by running `omarchy-migrate` (`run_post_upgrade_migrations`), so one migration reaches every population; a copy in the upgrade command would only be a second copy of the same predicate to keep correct. Such a migration must name the defect it clears and match what the old installer actually produced before deleting it. Leave safe administrator-authored files alone; if one still contains the vulnerable privileged action, preserve it under an inactive name rather than discarding custom content or leaving the action executable. A user config that depends on the same retired compatibility path may be repaired in that migration when doing so eliminates an overlapping migration, but only by matching and replacing the exact legacy path while preserving the rest of the file.
+1 -3
View File
@@ -20,9 +20,7 @@ Run `omarchy-restart-shell` after making changes to QML files.
[`docs/omarchy-shell.md`](../../docs/omarchy-shell.md) and
`shell/services/PluginRegistry.qml` for the current contract; fields such as
`activation` are optional.
- Entry-point QML files are `Item`s (not `ShellRoot`), and accept the
shell-injected properties `omarchyPath`, `shell`, `manifest`, and
`pluginRegistry` / `barWidgetRegistry` as appropriate.
- Entry-point QML files are `Item`s (not `ShellRoot`), and accept the shell-injected properties `omarchyPath`, `shell`, `manifest`, and `pluginRegistry` / `barWidgetRegistry` as appropriate. First-party plugins receive the host objects. Third-party plugins receive capability-scoped facades: ordinary plugins may look up and control only their own service and lifecycle, built-in clones retain narrow source-specific configuration and UI compatibility, menu plugins receive an application-library facade, and plugins can read detached scalar bar state; full-bar plugins additionally receive detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities must be stamped from trusted first-party manifests, and third-party registry views and bar configuration must be detached snapshots rather than shared objects. These facades reduce accidental authority but are not a same-process QML sandbox: a visual bar widget can walk its parent hierarchy to ordinary host objects. Authentication services must therefore remain outside both `ShellRoot._services` and the host QObject tree. Do not expose authentication services through new third-party-facing properties.
- Panel / overlay / menu plugins must expose `open(payloadJson)` and
`close()` lifecycle methods for `shell summon` and `shell hide`.
+1 -4
View File
@@ -15,10 +15,7 @@ Take a full-screen screenshot without opening the editor:
omarchy capture screenshot fullscreen save
```
The command prints the saved path and writes to the configured Pictures
directory. Use `omarchy screenshot` for the interactive smart-region flow.
Capture reference and candidate states as separate images when changing a
layer-shell surface or layout, then compare both.
The command writes to Omasnap's configured screenshot directory. Use `omarchy screenshot` for the interactive capture flow, which copies the capture and shows a timed preview. Use `omarchy screenshot --editor=overlay` to test annotation before output. Capture reference and candidate states as separate images when changing a layer-shell surface or layout, then compare both.
Record a short full-screen video for animation, transition, timing, capture, or
screen-recording changes:
+1 -1
View File
@@ -1,7 +1,7 @@
[Desktop Entry]
Version=1.0
Name=Docker
Exec=xdg-terminal-exec --app-id=TUI.tile -e lazydocker
Exec=xdg-terminal-exec --app-id=TUI.tile -e omarchy-launch-docker-tui
Terminal=false
Type=Application
Icon=docker
+3
View File
@@ -27,6 +27,7 @@ declare -A BINARY_TO_KEY
declare -A GROUP_DESCRIPTIONS
GROUP_DESCRIPTIONS[agent]="AI coding agent usage data"
GROUP_DESCRIPTIONS[ascii]="Text drawn as ASCII art"
GROUP_DESCRIPTIONS[audio]="Audio input and output controls"
GROUP_DESCRIPTIONS[bar]="Omarchy shell bar layout and settings"
GROUP_DESCRIPTIONS[battery]="Battery status helpers"
@@ -39,6 +40,7 @@ GROUP_DESCRIPTIONS[channel]="Omarchy release channel management"
GROUP_DESCRIPTIONS[clipboard]="Clipboard helpers"
GROUP_DESCRIPTIONS[cmd]="Command and shortcut helpers"
GROUP_DESCRIPTIONS[config]="System configuration helpers"
GROUP_DESCRIPTIONS[crash]="Crash notification controls"
GROUP_DESCRIPTIONS[debug]="Diagnostics and support logs"
GROUP_DESCRIPTIONS[finalize]="Finalize user setup"
GROUP_DESCRIPTIONS[default]="Default application selection"
@@ -63,6 +65,7 @@ GROUP_DESCRIPTIONS[monitor]="Monitor status helpers"
GROUP_DESCRIPTIONS[network]="Network status helpers"
GROUP_DESCRIPTIONS[notification]="Notification helpers"
GROUP_DESCRIPTIONS[mise]="Mise tool wrappers"
GROUP_DESCRIPTIONS[openclaw]="OpenClaw agent platform setup"
GROUP_DESCRIPTIONS[osd]="On-screen display status helpers"
GROUP_DESCRIPTIONS[pkg]="Package management helpers"
GROUP_DESCRIPTIONS[plugin]="Omarchy shell plugin and bar widget management"
+37 -1
View File
@@ -52,7 +52,7 @@ if omarchy-cmd-missing "$agent"; then
fi
# Agents launched from the keybinding or menu run unattended, so each one starts
# with its own spelling of "don't stop to ask". Pi has no approval prompts.
# with its own spelling of "don't stop to ask". Pi and Ori have none to skip.
case "$agent" in
opencode)
command=(opencode --auto)
@@ -82,14 +82,50 @@ grok)
command=(grok --permission-mode bypassPermissions)
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
;;
openclaw)
# The launcher owns onboarding and the gateway dance: OpenClaw's terminal UI
# must attach to the running gateway (the embedded `openclaw chat` refuses to
# start while the gateway owns the state directory). It has no permission
# prompts to skip, and --message seeds the session while keeping it
# interactive.
command=(omarchy-launch-openclaw --tui)
[[ -n ${prompt:-} ]] && command+=(--message "$prompt")
;;
codex)
command=(codex --approve-for-me)
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
;;
cursor-agent)
# --yolo covers commands only; the workspace trust dialog has its own flag.
# A one-word prompt naming a subcommand (update, login, help) still runs that
# subcommand after a bare --, so the agent subcommand is named outright, and
# -- after it keeps a prompt starting with a dash from being read as an option.
command=(cursor-agent --yolo --trust)
[[ -n ${prompt:-} ]] && command+=(agent -- "$prompt")
;;
hermes)
if [[ -n ${prompt:-} ]]; then
command=(env -u HERMES_SESSION_SOURCE hermes chat --yolo --tui "--query=$prompt")
else
command=(hermes --yolo)
fi
;;
muse)
# --approval-mode never skips the tool prompts but keeps Muse's own sandbox.
command=(muse --approval-mode never)
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
;;
omp)
command=(omp --auto-approve)
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
;;
ori)
# Ori is a harness launcher, and `ori code` is the agent it runs itself.
# A prompt alone means one headless turn there, printed after the turn ends,
# so --interactive is what seeds the session with it and keeps the window.
command=(ori code)
[[ -n ${prompt:-} ]] && command+=(--interactive --prompt "$prompt")
;;
pi)
command=(pi)
[[ -n ${prompt:-} ]] && command+=("$prompt")
+1 -1
View File
@@ -528,7 +528,7 @@ def fetch_codex_rpc():
try:
proc = subprocess.Popen(
[codex, "-s", "read-only", "-a", "untrusted", "app-server"],
[codex, "-s", "read-only", "-a", "on-request", "app-server"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
+8 -1
View File
@@ -6,6 +6,12 @@
set -e
# Install and upgrade callers can start this helper as root. Ignore their PATH
# so optional commands never fall through to a user-writable directory.
if (( EUID == 0 )); then
export PATH=/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin
fi
target_user=${OMARCHY_INSTALL_USER:-${SUDO_USER:-}}
if [[ -z $target_user && -n ${PKEXEC_UID:-} ]]; then
target_user=$(getent passwd "$PKEXEC_UID" | cut -d: -f1)
@@ -34,7 +40,8 @@ auth required pam_faillock.so authsucc
account include system-local-login
EOF
if omarchy-cmd-present fprintd-list && fprintd-list "$target_user" 2>/dev/null | grep -qi finger; then
if [[ -x /usr/bin/fprintd-list ]] &&
/usr/bin/fprintd-list "$target_user" 2>/dev/null | grep -qi finger; then
echo "Configuring lock screen fingerprint authentication..."
as_root tee /etc/pam.d/omarchy-lock-fingerprint >/dev/null <<'EOF'
#%PAM-1.0
+1217
View File
File diff suppressed because it is too large. Load diff
+4 -1
View File
@@ -109,7 +109,10 @@ right)
;;
esac
pixel=$(magick "$background_path" -auto-orient \
# Sample the first frame only. Without the selector a video background makes
# ImageMagick decode the whole file and emit one value per frame, and the match
# below then fails into the fallback colour.
pixel=$(magick "$background_path[0]" -auto-orient \
-resize "${screen_width}x${screen_height}^" \
-gravity center -extent "${screen_width}x${screen_height}" \
-gravity NorthWest -crop "$crop" +repage \
+104
View File
@@ -0,0 +1,104 @@
#!/bin/bash
# omarchy:summary=Shared helpers for animating the About branding (source this, don't run it).
# omarchy:group=branding
# omarchy:name=about-animation
# omarchy:hidden=true
# The About branding's animation: a sheen, and the frames that make it. *When*
# those frames are written stays with the caller, because that is inseparable
# from how the caller's window closes and resizes; this only says what to write,
# and where — it is handed a logo and knows nothing about About beyond that.
# A band of light leans across the logo, rests, and leans across again. Two
# columns per row puts it at 45 degrees on screen, where a cell is twice as tall
# as it is wide.
ESC=$'\e'
# Bright white, so the band does not depend on how the terminal reads bold. Foot's
# bold-text-in-bright brightens a bold regular colour into its bright counterpart,
# which turns a bold green logo bright green — exactly the colour a bright green
# band would have used, leaving a glint nobody can see. No regular colour
# brightens into bright white, so this one shows either way.
SHEEN_BAND="${ESC}[1m${ESC}[97m"
SHEEN_SLANT=2
SHEEN_HALF=2
SHEEN_FRAME_SECONDS=0.025
SHEEN_REST_TICKS=8
# Re-measure the grid every half second, the cadence a still logo already cost,
# rather than spawning a process per frame.
SHEEN_POLL_FRAMES=20
# A frame is one string holding every row of the logo, positioned and coloured,
# so a tick writes the whole logo at once and never shows it half drawn.
compose_frame() {
local centre=$1
local row line length at from to frame=""
for (( row = 0; row < SHEEN_ROWS; row++ )); do
line=${SHEEN_LINES[row]}
length=${#line}
# Where the band crosses this row. Running off the right needs no clamp,
# because a slice that starts past the end of a line is already empty, but a
# negative offset would count from the end of it instead of off the left.
at=$(( centre - row * SHEEN_SLANT ))
from=$(( at - SHEEN_HALF ))
from=$(( from < 0 ? 0 : from ))
to=$(( at + SHEEN_HALF + 1 ))
to=$(( to < 0 ? 0 : to ))
frame+="${ESC}[$((SHEEN_TOP + row));${SHEEN_LEFT}H"
frame+="${SHEEN_BASE}${line:0:from}${SHEEN_BAND}${line:from:to - from}${SHEEN_BASE}${line:to}"
done
SHEEN_COMPOSED=$frame
}
# Builds every frame up front: a tick that recomputed a logo's worth of colour
# spans in bash would not hold the frame rate, and the sweep is the same every
# time. Leaves them in SHEEN_FRAMES, and answers whether this logo can be
# animated at all — one it cannot put back exactly as it found it is one to leave
# alone, because nothing on screen would say the difference.
#
# sheen_build <logo-file> <top-row> <left-column> <base-colour> <columns-available>
sheen_build() {
local file=$1 columns=$5
SHEEN_TOP=$2
SHEEN_LEFT=$3
SHEEN_BASE=$4
SHEEN_LINES=()
# A failing redirection reports itself before 2>/dev/null would apply, so order
# it first: the caller's window must not get a shell error painted across it.
mapfile -t SHEEN_LINES 2>/dev/null <"$file" || return 1
SHEEN_ROWS=${#SHEEN_LINES[@]}
(( SHEEN_ROWS > 0 )) || return 1
local row line width=0
for (( row = 0; row < SHEEN_ROWS; row++ )); do
line=${SHEEN_LINES[row]}
# A renderer substitutes $1 to $9 for colours, so a logo written with those is
# not the text that reached the screen.
[[ $line == *'$'* ]] && return 1
# These frames slice the row by character and a terminal draws it by column,
# so one character has to be one cell — and everything that breaks that breaks
# it here. A wide glyph, a combining mark or a joined emoji is not one cell; a
# tab or an escape is one the renderer expanded itself; and a shell whose
# locale is counting bytes is not counting characters at all.
(( ${#line} == $(printf '%s' "$line" | LC_ALL=C.UTF-8 wc -L) )) || return 1
if (( ${#line} > width )); then
width=${#line}
fi
done
(( width > 0 && width <= columns )) || return 1
SHEEN_FRAMES=()
local centre last=$(( width + SHEEN_ROWS * SHEEN_SLANT + SHEEN_HALF ))
for (( centre = -SHEEN_HALF; centre <= last; centre++ )); do
compose_frame "$centre"
SHEEN_FRAMES+=("$SHEEN_COMPOSED")
done
}
+17 -4
View File
@@ -4,7 +4,13 @@
# omarchy:args=[--no-osd] [+N%|N%-|N%]
# omarchy:examples=omarchy brightness display apple | omarchy brightness display apple +5% | omarchy brightness display apple --no-osd 50%
device_cache="${XDG_RUNTIME_DIR:-/tmp}/omarchy-brightness-display-apple.device"
# Only cache under the user-private runtime dir. With no XDG_RUNTIME_DIR we skip
# caching (detect every run) rather than fall back to a predictable, world-writable
# /tmp path another user could pre-create.
device_cache=""
if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then
device_cache="$XDG_RUNTIME_DIR/omarchy-brightness-display-apple.device"
fi
no_osd=0
if [[ ${1:-} == "--no-osd" ]]; then
no_osd=1
@@ -28,9 +34,14 @@ find_apple_display_device() {
local cached=""
local device=""
if [[ -r $device_cache ]]; then
if [[ -n $device_cache && -r $device_cache ]]; then
read -r cached <"$device_cache" || true
if [[ -n $cached && -e $cached ]]; then
# Trust a cached value only if it still names a hiddev character device. A
# stale or unexpected cache (a regular file, a non-hiddev node) is ignored and
# we re-detect instead of handing an arbitrary path to asdcontrol. The globs
# are left unquoted on purpose: [[ ]] pattern-matches an unquoted right side,
# and quoting them would turn the match into a literal string comparison.
if [[ ( $cached == /dev/hiddev* || $cached == /dev/usb/hiddev* ) && -c $cached ]]; then
printf '%s\n' "$cached"
return 0
fi
@@ -39,7 +50,9 @@ find_apple_display_device() {
device="$(detect_apple_display_device)" || return 1
[[ -n $device ]] || return 1
printf '%s\n' "$device" >"$device_cache"
if [[ -n $device_cache ]]; then
printf '%s\n' "$device" >"$device_cache"
fi
printf '%s\n' "$device"
}
+21 -6
View File
@@ -15,8 +15,9 @@
# start.
#
# Env: OMARCHY_SCREENRECORD_DEBUG=true appends gpu-screen-recorder's stderr (and
# the picker target it was launched with) to /tmp/omarchy-screenrecord.log so
# users can attach a log when reporting capture failures.
# the picker target it was launched with) to
# $XDG_RUNTIME_DIR/omarchy-screenrecord.log so users can attach a log when
# reporting capture failures.
[[ -f ~/.config/user-dirs.dirs ]] && source ~/.config/user-dirs.dirs
OUTPUT_DIR="${OMARCHY_SCREENRECORD_DIR:-${XDG_VIDEOS_DIR:-$HOME/Videos}}"
@@ -34,9 +35,18 @@ WEBCAM_SIZE="medium"
RESOLUTION=""
FULLSCREEN="false"
STOP_RECORDING="false"
RECORDING_FILE="/tmp/omarchy-screenrecord-filename"
REGION_FILE="${XDG_RUNTIME_DIR:-/tmp}/omarchy-screenrecord-region"
LOG_FILE=$([[ ${OMARCHY_SCREENRECORD_DEBUG:-false} == "true" ]] && echo "/tmp/omarchy-screenrecord.log" || echo "/dev/null")
# Both of these live in the per-user runtime directory, which is 0700. What
# RECORDING_FILE holds is read back on stop and used as a path -- ffmpeg writes
# beside it, `mv` replaces it, and `rm -f` deletes its preview -- so a name in
# world-writable /tmp is a name any other local account can create first and
# then point wherever it likes. The debug log carries the recorder's stderr and
# the picked geometry, which is nobody else's business either. Fall back to
# the state directory when there is no session runtime dir -- :-/tmp would put
# both files back at the fixed names this comment describes.
RUNTIME_DIR="${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/omarchy}"
RECORDING_FILE="$RUNTIME_DIR/omarchy-screenrecord-filename"
REGION_FILE="$RUNTIME_DIR/omarchy-screenrecord-region"
LOG_FILE=$([[ ${OMARCHY_SCREENRECORD_DEBUG:-false} == "true" ]] && echo "$RUNTIME_DIR/omarchy-screenrecord.log" || echo "/dev/null")
for arg in "$@"; do
case "$arg" in
@@ -144,6 +154,11 @@ select_capture_target() {
}
start_screenrecording() {
mkdir -p "$RUNTIME_DIR" || return 1
# XDG_STATE_HOME may be outside a private home; protect existing fallbacks too.
if [[ -z ${XDG_RUNTIME_DIR:-} ]]; then
chmod 700 "$RUNTIME_DIR" || return 1
fi
local capture_args=()
local target
@@ -228,7 +243,7 @@ stop_screenrecording() {
omarchy-notification-send "Screen recording saved" "Open with Super + Alt + , (or click this)" \
-t 10000 --image "${preview:-$filename}" \
--exec "$(printf 'mpv %q' "$filename")"
--exec mpv -- "$filename"
# The shell loads the thumbnail into memory when the toast appears and never
# re-reads the file, so the preview only has to outlive that load -- not the
+11 -70
View File
@@ -2,81 +2,22 @@
# omarchy:summary=Take a screenshot
# omarchy:group=capture
# omarchy:args=[smart|region|windows|fullscreen] [slurp|copy|save] [--editor=<name>]
# omarchy:args=[smart|region|windows|fullscreen|scroll] [copy|save]
# omarchy:examples=omarchy screenshot | omarchy capture screenshot region
# omarchy:aliases=omarchy screenshot
[[ -f ~/.config/user-dirs.dirs ]] && source ~/.config/user-dirs.dirs
OUTPUT_DIR="${OMARCHY_SCREENSHOT_DIR:-${XDG_PICTURES_DIR:-$HOME/Pictures}}"
if [[ ! -d $OUTPUT_DIR ]]; then
mkdir -p "$OUTPUT_DIR"
omarchy-notification-send "Created screenshot directory: $OUTPUT_DIR" -t 2000
if [[ -n ${OMARCHY_SCREENSHOT_DIR:-} && -z ${OMASNAP_SCREENSHOT_DIR:-} ]]; then
export OMASNAP_SCREENSHOT_DIR="$OMARCHY_SCREENSHOT_DIR"
fi
pkill slurp && exit 0
SCREENSHOT_EDITOR="${OMARCHY_SCREENSHOT_EDITOR:-tensaku-edit}"
# Parse --editor flag from any position
ARGS=()
omasnap_args=()
for arg in "$@"; do
if [[ $arg == --editor=* ]]; then
SCREENSHOT_EDITOR="${arg#--editor=}"
else
ARGS+=("$arg")
fi
case "$arg" in
copy) omasnap_args+=(--copy) ;;
save) omasnap_args+=(--save) ;;
slurp) ;;
*) omasnap_args+=("$arg") ;;
esac
done
set -- "${ARGS[@]}"
MODE="${1:-smart}"
PROCESSING="${2:-slurp}"
# The picker leaves the screen freeze running (PID on its first output line)
# so grim captures the frozen overlay rather than live content shifting
# during teardown.
#
# Software-composited cursors (Hyprland's fallback on GPUs without working
# hardware cursors) are baked into the frames grim captures, so force
# hardware cursors until after grim runs and restore the setting on exit.
NO_HW_CURSORS=$(hyprctl getoption cursor:no_hardware_cursors -j | jq '.int')
set_no_hw_cursors() {
hyprctl eval "hl.config({ cursor = { no_hardware_cursors = $1 } })" &>/dev/null ||
hyprctl keyword cursor:no_hardware_cursors "$1" &>/dev/null
}
cleanup() {
[[ -n $FREEZE_PID ]] && kill $FREEZE_PID 2>/dev/null
set_no_hw_cursors "$NO_HW_CURSORS"
}
trap cleanup EXIT
set_no_hw_cursors 0
{ read -r FREEZE_PID; read -r SELECTION; } < <(omarchy-capture-region "$MODE" --keep-freeze)
[[ -z $SELECTION ]] && exit 0
FILENAME="screenshot-$(date +'%Y-%m-%d_%H-%M-%S').png"
FILEPATH="$OUTPUT_DIR/$FILENAME"
case "$PROCESSING" in
slurp)
grim -g "$SELECTION" "$FILEPATH" || exit 1
echo "$FILEPATH"
wl-copy --type image/png <"$FILEPATH"
# Best-effort: the screenshot is already saved and on the clipboard, so a
# notification outage must not report the capture itself as failed.
omarchy-notification-send "Screenshot saved to clipboard and file" "Edit with Super + Alt + , (or click this)" \
--image "$FILEPATH" \
--exec "$(printf '%q %q' "$SCREENSHOT_EDITOR" "$FILEPATH")" || true
;;
copy)
grim -g "$SELECTION" - | wl-copy --type image/png
;;
save)
grim -g "$SELECTION" "$FILEPATH" || exit 1
echo "$FILEPATH"
;;
esac
exec omasnap "${omasnap_args[@]}"
+4 -1
View File
@@ -8,7 +8,10 @@
set -euo pipefail
readonly MARGIN=40
readonly REGION_FILE="${XDG_RUNTIME_DIR:-/tmp}/omarchy-screenrecord-region"
# Has to resolve to whatever omarchy-capture-screenrecording writes, fallback
# included, or a recording without a session runtime dir anchors the camera to
# the whole monitor instead of the region it recorded.
readonly REGION_FILE="${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/omarchy}/omarchy-screenrecord-region"
usage() {
echo "Usage: omarchy-capture-webcam-resize <smaller|larger|reset|small|medium|large>" >&2
+6 -2
View File
@@ -34,7 +34,7 @@ validate_dev_checkout() {
link_dev_checkout() {
local checkout="$1"
[[ -d $checkout/.git ]] || git clone https://github.com/basecamp/omarchy.git "$checkout"
[[ -d $checkout/.git ]] || git clone https://github.com/omacom/omarchy.git "$checkout"
omarchy-dev-link "$checkout" --no-reboot
}
@@ -72,6 +72,10 @@ case "$channel" in
;;
esac
# A failure past this point leaves the channel switch half-applied, so say how
# to pick it back up rather than dying silently under set -e.
trap 'echo -e "\nThe channel switch did not complete. Review the error above, then rerun: omarchy-channel-set '"$channel"'" >&2' ERR
if [[ -z $dev_checkout && $OMARCHY_PATH != "/usr/share/omarchy" ]]; then
leaving_dev=1
fi
@@ -85,7 +89,7 @@ fi
omarchy-refresh-pacman "$pacman_channel"
# --ask 4 accepts omarchy <-> omarchy-dev replacement prompts without file overwrites.
sudo env OMARCHY_UPDATE_PACMAN=1 pacman -S --needed --noconfirm --ask 4 "${packages[@]}"
omarchy-update-pacman -S --needed --noconfirm --ask 4 "${packages[@]}"
if [[ -z $dev_checkout ]]; then
omarchy-dev-unlink --no-reboot
+67 -13
View File
@@ -25,6 +25,51 @@ valid_url() {
[[ $1 =~ ^https?:// ]]
}
# A printed path is only usable if it is a regular file inside DOWNLOAD_DIR.
# Forged records (leading-dash mpv options, paths with control chars, or
# anything that escaped the download directory) must not reach the click command.
resolve_download_file() {
local candidate=$1 file_real dir_real
[[ -n $candidate ]] || return 1
[[ $candidate != *$'\n'* && $candidate != *$'\r'* && $candidate != *$'\t'* ]] || return 1
[[ -f $candidate ]] || return 1
# Read to a NUL: command substitution strips trailing newlines, which would
# resolve a name ending in one to a different file that may well exist.
IFS= read -r -d '' file_real < <(realpath -ze -- "$candidate") || return 1
IFS= read -r -d '' dir_real < <(realpath -ze -- "$DOWNLOAD_DIR") || return 1
[[ $file_real != *$'\n'* && $file_real != *$'\r'* && $file_real != *$'\t'* ]] || return 1
# Trim the slash so a download directory of "/" still leaves a usable prefix.
[[ $file_real == "${dir_real%/}"/* ]] || return 1
printf '%s' "$file_real"
}
# yt-dlp prints the title JSON-encoded, so a newline or tab in page metadata is an
# escape sequence rather than a record boundary. This is toast text, never a command.
decode_title() {
local decoded
decoded=$(jq -r 'if type == "string" then . else empty end' <<<"$1" 2>/dev/null) || return 1
decoded=${decoded%%[[:cntrl:]]*} # keep what a person would read, drop the forgery
[[ -n $decoded && $decoded != -* ]] || return 1
printf '%s' "$decoded"
}
title_from_file() {
local name=${1##*/}
name=${name%.*}
name=${name//[$'\n\r\t']/}
if [[ -z $name || $name == -* ]]; then
printf '%s' "Video"
else
printf '%s' "$name"
fi
}
# Drive the Quickshell OSD — a single overlay that updates in place (like the
# volume/brightness bar), so download progress never stacks like notifications.
osd_progress() {
@@ -41,16 +86,19 @@ download_url() {
mkdir -p "$DOWNLOAD_DIR"
# Don't show anything until yt-dlp confirms there's actually a video to grab.
if ! yt-dlp --no-playlist --simulate --quiet --no-warnings "$url" >/dev/null 2>&1; then
if ! yt-dlp --no-playlist --simulate --quiet --no-warnings --no-exec --no-exec-before-download -- "$url" >/dev/null 2>&1; then
omarchy-notification-send -u critical -g 󰅖 "No video found for download" "$url"
exit 0
fi
osd_progress 0
# Stream the download: OMARCHY_PROG carries the percent (drives the OSD),
# OMARCHY_FILE (printed only after a successful move) carries title + path.
local line rest pct intpct last="" er nowms lastms=0 title="" filepath=""
# Stream the download: OMARCHY_PROG carries the percent (drives the OSD), and
# OMARCHY_FILE and OMARCHY_TITLE (printed only after a successful move) carry the
# path and the title. The title is JSON-encoded so metadata cannot forge a record,
# and the file is named after it: yt-dlp strips control characters from a filename
# with or without --restrict-filenames, so a record is still only ever one line.
local line pct intpct last="" er nowms lastms=0 title="" filepath="" resolved
while IFS= read -r line; do
case $line in
OMARCHY_PROG*)
@@ -67,22 +115,26 @@ download_url() {
osd_progress "$intpct"
;;
OMARCHY_FILE*)
rest=${line#OMARCHY_FILE$'\t'}
title=${rest%%$'\t'*}
filepath=${rest#*$'\t'}
resolved=$(resolve_download_file "${line#OMARCHY_FILE$'\t'}") || continue
filepath=$resolved
;;
OMARCHY_TITLE*)
title=$(decode_title "${line#OMARCHY_TITLE$'\t'}") || title=""
;;
esac
done < <(PYTHONUNBUFFERED=1 yt-dlp --no-playlist --restrict-filenames --no-simulate \
--quiet --no-warnings --progress --newline \
done < <(PYTHONUNBUFFERED=1 yt-dlp --no-playlist --no-simulate \
--quiet --no-warnings --no-exec --no-exec-before-download --progress --newline \
--progress-template $'download:OMARCHY_PROG\t%(progress._percent_str)s' \
--paths "$DOWNLOAD_DIR" -o '%(title)s [%(id)s].%(ext)s' \
--print $'after_move:OMARCHY_FILE\t%(title)s\t%(filepath)s' \
"$url" 2>&1)
--paths "$DOWNLOAD_DIR" -o '%(title)s.%(ext)s' \
--print $'after_move:OMARCHY_FILE\t%(filepath)s' \
--print $'after_move:OMARCHY_TITLE\t%(title)j' \
-- "$url" 2>&1)
osd_close
# after_move only prints on a successful download+move, so a captured path == success.
if [[ -n $filepath ]]; then
[[ -n $title ]] || title=$(title_from_file "$filepath")
((${#title} > 50)) && title="${title:0:50}…" # keep the toast compact
# Square, center-cropped thumbnail so the notification preview isn't stretched.
@@ -94,9 +146,11 @@ download_url() {
# Best-effort: the download already succeeded, and under `set -e` a failed
# toast would exit before the thumbnail cleanup below is ever scheduled.
# `--` keeps mpv from parsing a leading-dash filename as an option; the path
# is one discrete argument, so it never reaches a shell.
omarchy-notification-send -g 󰄬 "Download complete" "$title" \
-t 10000 --image "${preview:-$filepath}" \
--exec "$(printf 'mpv %q' "$filepath")" || true
--exec mpv -- "$filepath" || true
# The shell loads the thumbnail into memory when the toast appears and never
# re-reads the file, so the preview only has to outlive that load, not the
+1 -1
View File
@@ -30,7 +30,7 @@ open_image() {
local path="$1"
[[ -r $path ]] || exit 1
exec tensaku-edit "$path"
exec omasnap "$path"
}
open_text() {
+1 -1
View File
@@ -3,7 +3,7 @@
# omarchy:summary=Check whether any required commands are missing
for cmd in "$@"; do
if ! command -v "$cmd" &>/dev/null; then
if ! command -v -- "$cmd" &>/dev/null; then
exit 0
fi
done
+1 -1
View File
@@ -3,7 +3,7 @@
# omarchy:summary=Check whether all required commands are available
for cmd in "$@"; do
command -v "$cmd" &>/dev/null || exit 1
command -v -- "$cmd" &>/dev/null || exit 1
done
exit 0
+73
View File
@@ -0,0 +1,73 @@
#!/bin/bash
# omarchy:summary=Silence crash notifications for one program, or list what is silenced
# omarchy:args=[--] [<program>] [on|off|toggle]
# omarchy:examples=omarchy crash mute | omarchy crash mute hyprland | omarchy crash mute /usr/bin/hyprland | omarchy crash mute hyprland off
# The flag omarchy-crash-watch reads before announcing a crash. Muting is per
# program; Trigger > Toggle > Crash Capture is the switch for all of them.
set -uo pipefail
readonly MUTES="$HOME/.local/state/omarchy/toggles/crash-ignore"
usage() {
echo "Usage: omarchy crash mute [--] [<program>] [on|off|toggle]" >&2
}
# Only regular files, because that is all the watcher honours: anything else in
# there would be reported as muted while the crashes kept arriving. The dotted
# glob is for a program legitimately called .hidden, and `.` and `..` fail the
# same -f test that keeps them out.
list() {
local entry found=0
for entry in "$MUTES"/* "$MUTES"/.*; do
[[ -f $entry ]] || continue
printf '%s\n' "${entry##*/}"
found=1
done
((found)) || echo "No programs muted. Crashes all notify."
}
# A program may be named -h, and the router answers that with its own help
# before this ever runs. `omarchy crash mute -- -h` is the way through.
[[ ${1:-} == "--" ]] && shift
if (($# == 0)); then
list
exit 0
fi
program=$1
action=${2:-on}
# The watcher keys the mute on the executable's basename, so accept the path it
# reports as readily as the name, and reduce either the same way it does.
program=${program##*/}
if [[ -z $program || $program == "." || $program == ".." ]]; then
echo "Not a program name: $1" >&2
usage
exit 1
fi
case "$action" in
on|off|toggle) ;;
*)
echo "Not an action: $action" >&2
usage
exit 1
;;
esac
omarchy-toggle "crash-ignore/$program" "$action" || exit 1
# Report what is now true rather than what was asked for: the flag is what the
# watcher reads, and a toggle does not say which way it went.
if omarchy-toggle-enabled "crash-ignore/$program"; then
echo "Muted crash notifications for $program."
else
echo "Crash notifications for $program are back on."
fi
+35 -12
View File
@@ -24,9 +24,7 @@ readonly ignore_pattern=${OMARCHY_CRASH_IGNORE:-}
declare -A last_notified
announce() {
local comm=$1 pid=$2 exe=$3 signal=$4 exec_command
exec_command=$(printf 'omarchy-agent-crash %q %q %q %q' "$pid" "$comm" "$exe" "$signal")
local comm=$1 pid=$2 exe=$3 signal=$4
# The shell owns org.freedesktop.Notifications, so a shell crash takes the
# notification server down with it and a toast sent into that gap is lost.
@@ -35,25 +33,32 @@ announce() {
omarchy-notification-wait || return 1
# --exec rather than a libnotify action: the shell runs clicks from its own
# omarchy-exec hint and never emits ActionInvoked. Keeps the default
# "omarchy-action" app name too, the only one shouldBypassDnd() lets through.
# hint and never emits ActionInvoked. Keeps the default "omarchy-action" app
# name, the only one shouldBypassDnd() lets through. Crash details ride as
# discrete argv words, so a hostile process name can't be reparsed as a
# command. --exec consumes the rest of the line, so it comes last.
omarchy-notification-send \
--urgency critical \
--glyph "$CRASH_GLYPH" \
--exec "$exec_command" \
"Process crashed: $comm" \
"Click to diagnose with AI"
"Click to diagnose with AI" \
--exec omarchy-agent-crash "$pid" "$comm" "$exe" "$signal"
}
# -n 0 so a restart does not re-announce crashes already dealt with.
journalctl -f -n 0 -o json "MESSAGE_ID=$COREDUMP_MESSAGE_ID" 2>/dev/null |
while IFS= read -r entry; do
# A dash for a field that is empty as well as one that is missing: tab is
# IFS whitespace, so an empty field collapses into the next delimiter and
# every field after it shifts along one. A process can set its own comm to
# nothing, and that crash used to be read as somebody else's and dropped.
IFS=$'\t' read -r uid comm pid exe signal < <(
jq -r '[(._UID // "-"),
(.COREDUMP_COMM // "-"),
(.COREDUMP_PID // "-"),
(.COREDUMP_EXE // "-"),
(.COREDUMP_SIGNAL_NAME // "-")] | @tsv' <<<"$entry" 2>/dev/null
jq -r 'def field: if . == null or . == "" then "-" else . end;
[(._UID | field),
(.COREDUMP_COMM | field),
(.COREDUMP_PID | field),
(.COREDUMP_EXE | field),
(.COREDUMP_SIGNAL_NAME | field)] | @tsv' <<<"$entry" 2>/dev/null
)
[[ $pid =~ ^[0-9]+$ ]] || continue
@@ -71,11 +76,29 @@ journalctl -f -n 0 -o json "MESSAGE_ID=$COREDUMP_MESSAGE_ID" 2>/dev/null |
name=$comm
[[ $exe == /* ]] && name=${exe##*/}
# A process can set its own comm to anything prctl takes, slashes included,
# and a crash with no recorded executable falls back to it. The mute below
# turns this name into a path, so keep it one component: a crash must not
# reach a flag outside crash-ignore/, nor have a diagnosis write one there.
name=${name##*/}
# What that leaves is not always a name. "/" leaves nothing, which is no
# kind of array subscript and no kind of toast; a dot component names a
# directory rather than a flag, so a mute on it would touch that directory
# and then never match; and a dash is what the read above puts there when
# the crash recorded no name at all.
[[ -n $name && $name != "-" && $name != "." && $name != ".." ]] || name=unknown
[[ -n $ignore_pattern && $name =~ $ignore_pattern ]] && continue
# Never announce our own machinery, or it notifies about itself.
[[ $name == omarchy-crash-* || $name == omarchy-agent-* ]] && continue
# Muted at the end of a diagnosis, when the user was offered it and said
# yes. A flag per program rather than one list, so omarchy-crash-mute can
# lift one without reading, rewriting and re-parsing the rest.
omarchy-toggle-enabled "crash-ignore/$name" && continue
now=$EPOCHSECONDS
(((now - ${last_notified[$name]:-0}) < dedupe_seconds)) && continue
+40 -4
View File
@@ -1,7 +1,7 @@
#!/bin/bash
# omarchy:summary=Set and launch the default coding agent
# omarchy:args=[pi|omp|opencode|claude|codex|grok|agy|copilot|crush]
# omarchy:args=[pi|omp|opencode|ori|claude|codex|grok|openclaw|agy|hermes|copilot|crush|cursor-agent|muse]
# omarchy:examples=omarchy default agent | omarchy default agent codex | omarchy default agent claude
installing=false
@@ -27,25 +27,57 @@ case "$1" in
pi) agent="pi"; name="Pi" ;;
omp | oh-my-pi) agent="omp"; name="Oh My Pi"; agent_package="github:can1357/oh-my-pi" ;;
opencode | open-code) agent="opencode"; name="OpenCode" ;;
ori | openrouter) agent="ori"; name="Ori"; agent_package="github:OpenRouterLabs/ori-releases" ;;
claude | claude-code) agent="claude"; name="Claude Code" ;;
codex) agent="codex"; name="Codex" ;;
crush) agent="crush"; name="Crush" ;;
grok) agent="grok"; name="Grok"; agent_package="npm:@xai-official/grok" ;;
openclaw) agent="openclaw"; name="OpenClaw"; agent_installer="omarchy-install-openclaw-cli" ;;
agy | antigravity | antigravity-cli | gemini | gemini-cli) agent="agy"; name="Antigravity"; agent_package="antigravity-cli" ;;
hermes) agent="hermes"; name="Hermes"; agent_installer="omarchy-install-hermes-cli" ;;
copilot | github-copilot) agent="copilot"; name="GitHub Copilot" ;;
muse | muse-code | musecode)
agent="muse"; name="Muse Code"
# Meta's launcher verifies and updates the native binary for this platform.
agent_package="http:muse[url=https://api.meta.ai/muse-launcher.sh,bin=muse,version_list_url=https://api.meta.ai/muse-code/channels/muse-stable,version_json_path=.version]"
;;
cursor | cursor-agent) agent="cursor-agent"; name="Cursor CLI" ;;
*)
echo "Usage: omarchy-default-agent <pi|omp|opencode|claude|codex|grok|agy|copilot|crush>"
echo "Usage: omarchy-default-agent <pi|omp|opencode|ori|claude|codex|grok|openclaw|agy|hermes|copilot|crush|cursor-agent|muse>"
exit 1
;;
esac
agent_package=${agent_package:-$agent}
if [[ $installing == "false" ]] && ! mise where "$agent_package" &>/dev/null; then
# Hermes reaches mise through its own installer rather than straight from
# here: it needs its interpreter pinned, and a bare `mise use` has nowhere to
# say so. See omarchy-install-hermes-cli. OpenClaw comes from its pacman
# package the same way; see omarchy-install-openclaw-cli.
if [[ -n ${agent_installer:-} ]]; then
# Not omarchy-cmd-present: the stub is on PATH from first boot and says
# nothing about whether Hermes is installed behind it. Treating a cold stub
# as installed skips the floating terminal and runs the minute-long install
# inside the menu action instead.
agent_present() { "$agent_installer" --check; }
agent_install() { "$agent_installer" --now; }
else
# Anything at the wrapper's path other than the wrapper is the user's own
# install, such as the symlink Cursor's installer leaves. A mise copy would
# only shadow it, since the mise shims precede ~/.local/bin on PATH.
user_install() {
[[ -x $HOME/.local/bin/$agent ]] &&
{ [[ -L $HOME/.local/bin/$agent ]] || ! grep -q '^mise use -g' "$HOME/.local/bin/$agent"; }
}
agent_present() { user_install || mise where "$agent_package" &>/dev/null; }
agent_install() { user_install || mise use -g "$agent_package"; }
fi
if [[ $installing == "false" ]] && ! agent_present; then
exec omarchy-launch-floating-terminal-with-presentation omarchy-default-agent --install "$agent"
fi
if ! mise use -g "$agent_package"; then
if ! agent_install; then
if [[ $installing == "true" ]]; then
echo "Could not install $name with mise" >&2
else
@@ -54,6 +86,10 @@ if ! mise use -g "$agent_package"; then
exit 1
fi
if [[ $agent == "claude" ]]; then
omarchy-install-chromium-claude 2>/dev/null || true
fi
mkdir -p "$(dirname "$agent_file")"
printf '%s\n' "$agent" >"$agent_file"
+5 -2
View File
@@ -17,6 +17,9 @@ if (($# == 0)); then
read -r editor <"$editor_file"
fi
# sublime-text-4 ships `subl`; older defaults stored `sublime_text`.
[[ $editor == sublime_text ]] && editor=subl
[[ -n $editor ]] && echo "$editor" || echo "nvim"
exit 0
fi
@@ -25,10 +28,10 @@ case "$1" in
code) selection="code"; editor="code"; name="VSCode"; glyph= ;;
cursor) selection="cursor"; editor="cursor"; name="Cursor"; glyph= ;;
zed | zeditor) selection="zed"; editor="zeditor"; name="Zed"; glyph= ;;
sublime_text) selection="sublime_text"; editor="sublime_text"; name="Sublime Text"; glyph= ;;
sublime_text | subl) selection="sublime_text"; editor="subl"; name="Sublime Text"; glyph= ;;
helix) selection="helix"; editor="helix"; name="Helix"; glyph= ;;
vim) selection="vim"; editor="vim"; name="Vim"; glyph= ;;
emacs) selection="emacs"; editor="emacs"; name="Emacs"; glyph= ;;
emacs) selection="emacs"; editor="emacs"; name="Emacs"; glyph= ;;
nvim) selection="nvim"; editor="nvim"; name="Neovim"; glyph= ;;
*)
echo "Usage: omarchy-default-editor <code|cursor|zed|sublime_text|helix|vim|emacs|nvim>"
+6 -2
View File
@@ -34,8 +34,12 @@ systemd, shell, or app-launcher environment; reboot to make every layer agree.
Affects only \$OMARCHY_PATH-resolved trees: bin/, default/, shell/,
themes/, applications/, config/. Files installed at fixed system paths
(/etc/, /usr/lib/systemd/, udev rule bodies, /etc/skel after user
creation, /usr/share/plymouth) are NOT covered — for those, use
omarchy-dev-pkg-test to build and install the package from the checkout.
creation) are NOT covered — for those, use omarchy-dev-pkg-test to build
and install the package from the checkout.
The Plymouth and SDDM themes under /usr/share are the exception: omarchy
plymouth set and omarchy refresh plymouth republish them from the checkout,
reading this link's authorization out of the root-owned /etc/omarchy.conf.
Also writes $sudoers_file so sudo resolves omarchy-*
from the checkout instead of the packaged copies. That part takes effect
+14 -4
View File
@@ -129,7 +129,8 @@ term_pt_for() {
'BEGIN { printf "%d", int(s * p / b + 0.5) }'
}
# Set the font point size in every terminal config that exists. Family is left
# Set the font point size in terminal configs, creating Kitty overrides when
# it inherits its size from the system config. Family is left
# untouched — that is omarchy-font-set's job. Live-reload signals mirror
# omarchy-font-set; foot has no reload signal, so running instances are nudged.
set_terminal_size() {
@@ -139,8 +140,13 @@ set_terminal_size() {
sed -i -E "s/^size[[:space:]]*=.*/size = $pt/" ~/.config/alacritty/alacritty.toml
fi
if [[ -f ~/.config/kitty/kitty.conf ]]; then
sed -i -E "s/^font_size[[:space:]]+.*/font_size $pt.0/" ~/.config/kitty/kitty.conf
if [[ -f ~/.config/kitty/kitty.conf ]] || omarchy-cmd-present kitty; then
mkdir -p ~/.config/kitty
if grep -qE '^[[:space:]]*font_size[[:space:]]+' ~/.config/kitty/kitty.conf 2>/dev/null; then
sed --follow-symlinks -i -E "s/^[[:space:]]*font_size[[:space:]]+.*/font_size $pt.0/" ~/.config/kitty/kitty.conf
else
printf '\nfont_size %s.0\n' "$pt" >>~/.config/kitty/kitty.conf
fi
pkill -USR1 kitty 2>/dev/null || true
fi
@@ -177,9 +183,13 @@ term_current_pt() {
elif [[ -f ~/.config/alacritty/alacritty.toml ]]; then
grep -oP '^size[[:space:]]*=[[:space:]]*\K[0-9.]+' ~/.config/alacritty/alacritty.toml | head -1
elif [[ -f ~/.config/kitty/kitty.conf ]]; then
grep -oP '^font_size[[:space:]]+\K[0-9.]+' ~/.config/kitty/kitty.conf | head -1
local pt
pt=$(grep -oP '^[[:space:]]*font_size[[:space:]]+\K[0-9.]+' ~/.config/kitty/kitty.conf | tail -1)
echo "${pt:-$TERM_DEFAULT_PT}"
elif [[ -f ~/.config/foot/foot.ini ]]; then
grep -oP ':size=\K[0-9.]+' ~/.config/foot/foot.ini | head -1
elif omarchy-cmd-present kitty; then
echo "$TERM_DEFAULT_PT"
fi
}
+18
View File
@@ -6,6 +6,18 @@
set -euo pipefail
# Whenever this runs as root — invoked directly through the passwordless
# sudoers rule, or re-execed by require_root below — sudo's secure_path decides
# where a bare helper resolves, and a dev link (etc/sudoers.d/omarchy-dev-path)
# prepends a user-writable checkout bin/ to it. Every helper this script calls
# by bare name (dirname, install, tee, rm, nmcli, systemctl, awk) is a system
# tool, never an omarchy-* command, so pin PATH to trusted system directories
# and keep root from resolving one out of that checkout. The unprivileged
# wrapper phase keeps the caller's PATH so it can still find sudo/pkexec.
if (( EUID == 0 )); then
export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin
fi
NM_DNS_CONF=/etc/NetworkManager/conf.d/20-omarchy-dns.conf
provider_from_arg() {
@@ -137,6 +149,9 @@ write_networkmanager_dns() {
local servers="$1"
install -d -m 0755 "$(dirname "$NM_DNS_CONF")"
# omarchy:heredoc-expands paths=none -- $servers is a normalized, single-line
# DNS server list written as data, not a path or command; nothing user-writable
# is resolved or executed from the root-owned drop-in.
cat >"$NM_DNS_CONF" <<EOF
# Managed by omarchy-dns. Remove this file or run omarchy dns DHCP to use DHCP DNS again.
[global-dns]
@@ -290,6 +305,9 @@ Custom)
split_dns_servers "$dns_servers"
write_networkmanager_dns "$dns_servers"
set_connection_dns "$ipv4_dns" "$ipv6_dns"
# omarchy:heredoc-expands paths=none -- $dns_servers is a normalized,
# single-line DNS server list; the //,/ turns its comma separators into the
# spaces resolved.conf wants. It is written as data, not a path or command.
tee /etc/systemd/resolved.conf >/dev/null <<EOF
[Resolve]
DNS=${dns_servers//,/ }
+8 -2
View File
@@ -30,8 +30,14 @@ if [[ -f ~/.config/alacritty/alacritty.toml ]]; then
sed -i "s/family = \".*\"/family = \"$font_name\"/g" ~/.config/alacritty/alacritty.toml
fi
if [[ -f ~/.config/kitty/kitty.conf ]]; then
sed -i "s/^font_family .*/font_family $font_name/g" ~/.config/kitty/kitty.conf
if [[ -f ~/.config/kitty/kitty.conf ]] || omarchy-cmd-present kitty; then
mkdir -p ~/.config/kitty
if grep -qE '^[[:space:]]*font_family[[:space:]]+' ~/.config/kitty/kitty.conf 2>/dev/null; then
kitty_font_name=$(printf '%s' "$font_name" | sed 's/[\\&/]/\\&/g')
sed --follow-symlinks -i -E "s/^[[:space:]]*font_family[[:space:]]+.*/font_family $kitty_font_name/" ~/.config/kitty/kitty.conf
else
printf '\nfont_family %s\n' "$font_name" >>~/.config/kitty/kitty.conf
fi
pkill -USR1 kitty
fi
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
# omarchy:summary=Check that a git URL names a repository, not a transport helper
# omarchy:args=<git-url>
# omarchy:hidden=true
set -euo pipefail
# git picks a remote helper -- an executable it runs at clone time -- out of a URL
# in exactly two shapes, and no others: `<helper>::<address>`, and
# `<scheme>://<address>` for any scheme git does not handle itself. A single
# colon is always scp-style ssh, and a bare path is always a path; neither can
# reach a helper. So constraining those two shapes covers the whole surface.
#
# The `::` shape is refused outright, because no helper reachable that way is one
# a theme or plugin URL has business naming, and `ext::` runs a shell command.
# The `://` shape cannot be refused the same way, since it is also how every
# legitimate URL arrives -- so it is allowlisted instead. The list is the
# transports git still connects itself, `git+ssh` and `ssh+git` included: those
# two are spelled like a helper but are read as plain ssh. `ext` and `fd` are
# left out deliberately -- git ships a helper for each, and `ext` runs whatever
# command the URL carries.
TRANSPORTS=(ssh git git+ssh ssh+git http https ftp ftps file)
fail() {
echo "omarchy-git-url-check: $*" >&2
exit 1
}
url="${1-}"
if [[ -z $url ]]; then
fail "a git URL is required"
fi
if [[ $url == -* || $url =~ ^[A-Za-z0-9][A-Za-z0-9+.-]*:: ]]; then
fail "'$url' names a git option or transport helper, not a repository."
fi
if [[ $url =~ ^([A-Za-z0-9][A-Za-z0-9+.-]*):// ]]; then
scheme="${BASH_REMATCH[1]}"
for transport in "${TRANSPORTS[@]}"; do
if [[ $scheme == "$transport" ]]; then
exit 0
fi
done
fail "'$url' names the '$scheme' transport, which Omarchy does not clone from."
fi
+38 -3
View File
@@ -30,6 +30,35 @@ MKINITCPIO_CONF="/etc/mkinitcpio.conf.d/omarchy_resume.conf"
SWAP_FILE="/swap/swapfile"
RESUME_DROP_IN="/etc/limine-entry-tool.d/resume.conf"
install_root_file() {
local source="$1"
local destination="$2"
local mode="$3"
local stage
stage=$(sudo /usr/bin/mktemp -- "${destination%/*}/.${destination##*/}.omarchy.XXXXXX") || return 1
safe_stage_path "$stage" "$destination" || return 1
if sudo /usr/bin/install -m "$mode" -o root -g root -T "$source" "$stage" &&
sudo /usr/bin/mv -Tf -- "$stage" "$destination"; then
return 0
else
safe_stage_path "$stage" "$destination" && sudo /usr/bin/rm -f -- "$stage"
return 1
fi
}
safe_stage_path() {
local stage="$1"
local destination="$2"
local prefix suffix
prefix="${destination%/*}/.${destination##*/}.omarchy."
[[ $stage == "$prefix"* ]] || return 1
suffix=${stage#"$prefix"}
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
}
# Check if hibernation is already configured
if [[ -f $MKINITCPIO_CONF ]] && grep -q "^HOOKS+=(resume)$" "$MKINITCPIO_CONF"; then
# Fix empty resume_offset if btrfs map-swapfile failed during initial setup
@@ -83,14 +112,20 @@ if ! swapon --show | grep -q "$SWAP_FILE"; then
sudo swapon -p 0 "$SWAP_FILE"
fi
# Ensure keyboard backlight doesn't prevent sleep
# Install this before writing the resume marker so a failed install remains
# retryable through the normal setup command.
if ! install_root_file "$OMARCHY_PATH/default/systemd/system-sleep/keyboard-backlight" \
/usr/lib/systemd/system-sleep/keyboard-backlight 0755; then
echo "Could not install the keyboard-backlight system-sleep hook" >&2
exit 1
fi
# Add resume hook to mkinitcpio
sudo mkdir -p /etc/mkinitcpio.conf.d
echo "Adding resume hook to $MKINITCPIO_CONF"
echo "HOOKS+=(resume)" | sudo tee "$MKINITCPIO_CONF" >/dev/null
# Ensure keyboard backlight doesn't prevent sleep
sudo cp -p "$OMARCHY_PATH/default/systemd/system-sleep/keyboard-backlight" /usr/lib/systemd/system-sleep/
# Add resume= kernel parameters so the initramfs resume hook knows where to find the
# hibernation image. Without these, resume happens late (after GPU drivers load) and fails.
if [[ ! -f $RESUME_DROP_IN ]]; then
+12
View File
@@ -11,6 +11,18 @@ if (( $# < 1 )); then
fi
HOOK=$1
# Hook names are fixed labels chosen by Omarchy code (post-update, theme-set,
# font-set). The name becomes a filename under the hooks directory. A slash
# would turn it into directory levels, and a bare `.` or `..` would point bash
# at the directory itself or its parent. Refuse those rather than follow them.
# Dots inside a name (a..b) are fine; once slashes are out, only the whole
# name being `.` or `..` can leave the directory.
if [[ -z $HOOK || $HOOK == */* || $HOOK == "." || $HOOK == ".." ]]; then
echo "Invalid hook name: $HOOK" >&2
exit 2
fi
HOOK_PATH="$HOME/.config/omarchy/hooks/$1"
HOOK_DIR="$HOOK_PATH.d"
shift
+13 -2
View File
@@ -15,8 +15,19 @@ fi
HOOK_TYPE=$1
HOOK_FILE=$2
# Hook types are the same labels omarchy-hook runs (post-update, theme-set).
# The type becomes a directory under the hooks directory. A slash would turn
# it into directory levels. A bare `.` or `..` is a name the runner already
# refuses, so installing under it would write a hook nothing can run. Refuse
# those rather than mkdir/cp into them. Dots inside a name (a..b) are fine.
if [[ -z $HOOK_TYPE || $HOOK_TYPE == */* || $HOOK_TYPE == "." || $HOOK_TYPE == ".." ]]; then
echo "Invalid hook name: $HOOK_TYPE" >&2
exit 2
fi
HOOK_DIR="$HOME/.config/omarchy/hooks/$HOOK_TYPE.d"
HOOK_NAME=$(basename "$HOOK_FILE")
HOOK_NAME=$(basename -- "$HOOK_FILE")
HOOK_PATH="$HOOK_DIR/$HOOK_NAME"
if [[ ! -f $HOOK_FILE ]]; then
@@ -25,7 +36,7 @@ if [[ ! -f $HOOK_FILE ]]; then
fi
mkdir -p "$HOOK_DIR"
cp "$HOOK_FILE" "$HOOK_PATH"
cp -- "$HOOK_FILE" "$HOOK_PATH"
chmod 755 "$HOOK_PATH"
echo "Installed $HOOK_TYPE hook: $HOOK_PATH"
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# omarchy:summary=Match the Dell XPS 13 DX13260 that requires the sidecar amplifier workaround.
product_sku="${OMARCHY_DMI_PRODUCT_SKU:-/sys/class/dmi/id/product_sku}"
omarchy-hw-match "DX13260" &&
grep -qix "0E53" "$product_sku" 2>/dev/null
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# omarchy:summary=Detect whether an Elgato Cam Link 4K is plugged in.
grep -qsx "Cam Link 4K" /sys/bus/usb/devices/*/product
+2 -2
View File
@@ -3,5 +3,5 @@
# omarchy:summary=Match against the computer's DMI product name or product family (case-insensitive).
# omarchy:args=<pattern>
grep -qi "$1" /sys/class/dmi/id/product_name 2>/dev/null ||
grep -qi "$1" /sys/class/dmi/id/product_family 2>/dev/null
grep -qi -- "$1" /sys/class/dmi/id/product_name 2>/dev/null ||
grep -qi -- "$1" /sys/class/dmi/id/product_family 2>/dev/null
+32 -2
View File
@@ -11,6 +11,14 @@ MONITOR_LUA="$HOME/.config/hypr/monitors.lua"
INTERNAL=$(omarchy-hyprland-monitor-laptop)
# INTERNAL is written into generated Lua and hyprctl eval/dispatch below, so a
# name that is not a plain connector string could execute on the next reload.
# Names come from hyprctl; a user-created headless output can carry anything.
if [[ -n $INTERNAL && ! $INTERNAL =~ ^[A-Za-z0-9._-]+$ ]]; then
echo "Refusing unsafe internal monitor name" >&2
exit 1
fi
valid_scale() {
[[ $1 =~ ^[0-9]+([.][0-9]+)?$ ]]
}
@@ -118,9 +126,17 @@ remember_internal_scale() {
store_internal_scale "$scale"
}
# $1 is the configured scale when the caller has already read it, so one sync
# does not parse the config twice.
read_monitor_scale() {
local scale
scale=$(configured_monitor_scale)
if (( $# )); then
scale="$1"
else
scale=$(configured_monitor_scale)
fi
if valid_scale "$scale"; then
echo "$scale"
return
@@ -159,11 +175,25 @@ enable_internal_output() {
sync_internal_scale() {
[[ -n $INTERNAL ]] || return 0
local configured_scale
local desired_scale
local active_scale
desired_scale=$(read_monitor_scale)
configured_scale=$(configured_monitor_scale)
active_scale=$(current_internal_scale)
# A config without a usable number -- the default "auto", or an expression
# only Hyprland's Lua can evaluate -- delegates the scale to the compositor,
# so whatever it resolved for the enabled panel IS the configured scale.
# There is no number to correct it toward: substituting one makes the scale
# flap between that number and the compositor's own value on every idle-wake.
# Only a panel that is off altogether still gets a hand below, from the
# remembered scale.
if ! valid_scale "$configured_scale" && valid_scale "$active_scale"; then
return 0
fi
desired_scale=$(read_monitor_scale "$configured_scale")
scales_match "$active_scale" "$desired_scale" && return 0
enable_internal_output "$desired_scale"
+7
View File
@@ -28,6 +28,13 @@ off() {
exit 1
fi
# The name is written into generated Lua below, so only a plain connector
# name may pass; anything else could execute on the next reload.
if [[ ! $INTERNAL =~ ^[A-Za-z0-9._-]+$ ]]; then
omarchy-notification-send -g 󰍹 "Refusing unsafe monitor name"
exit 1
fi
if ! omarchy-hyprland-monitor-external-active; then
omarchy-notification-send -g 󰍹 "Can't disable the only active display"
exit 1
@@ -22,6 +22,15 @@ on() {
exit 1
fi
# Both names are written into generated Lua below, so only plain connector
# names may pass; a user-created headless output can carry any name.
for output in "$INTERNAL" "$EXTERNAL"; do
if [[ ! $output =~ ^[A-Za-z0-9._-]+$ ]]; then
omarchy-notification-send -g 󰍹 "Refusing unsafe monitor name"
exit 1
fi
done
omarchy-hyprland-toggle $DISABLE_TOGGLE off
if omarchy-hyprland-toggle-disabled $TOGGLE; then
+8
View File
@@ -80,6 +80,14 @@ set_scale() {
local width="$(echo "$monitor_info" | jq -r '.width')"
local height="$(echo "$monitor_info" | jq -r '.height')"
local refresh_rate="$(echo "$monitor_info" | jq -r '.refreshRate')"
# active_monitor is written into the Lua string eval'd below, so only a plain
# connector name may pass; a hostile output name could execute otherwise.
if [[ ! $active_monitor =~ ^[A-Za-z0-9._-]+$ ]]; then
echo "Refusing unsafe monitor name" >&2
exit 1
fi
local new_scale="$(clean_scale "$requested_scale" "$width" "$height")"
# GTK only honors integer GDK_SCALE values, so persist the nearest whole
# factor even when the monitor scale itself is fractional.
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# omarchy:summary=Install the Claude desktop app
# omarchy:requires-sudo=true
set -euo pipefail
echo "Installing Claude..."
omarchy-pkg-add claude-desktop
echo "Opening Claude..."
setsid uwsm-app -- /usr/bin/claude-desktop >/dev/null 2>&1 &
echo ""
echo "Claude has been installed."
+162
View File
@@ -0,0 +1,162 @@
#!/bin/bash
# omarchy:summary=Install the Hermes desktop app
# omarchy:requires-sudo=true
set -e
if (( EUID == 0 )); then
echo "Run this command as your desktop user, without sudo." >&2
exit 1
fi
echo "Installing Hermes Desktop..."
omarchy-pkg-add hermes-desktop
if [[ ! -r /usr/share/hermes-desktop/install.sh || ! -r /usr/share/hermes-desktop/runtime.patch ]] ||
! release_commit=$(jq -er 'select(.branch == "main") | .commit | select(test("^[0-9a-f]{40}$"))' /opt/hermes-desktop/resources/install-stamp.json 2>/dev/null); then
echo "The installed Hermes package cannot prepare in-app updates. Run 'omarchy update', then try again." >&2
exit 1
fi
# If Hermes was already installed for the terminal, the app supersedes it: one
# machine, one Hermes. This drops that copy so the terminal, the default agent
# and the app all end up on the app's installation.
omarchy-install-hermes-cli || true
# Keep the runtime at the root even when invoked from a Hermes profile.
HERMES_HOME=$(realpath -ms -- "${HERMES_HOME:-$HOME/.hermes}")
home_parent=$(dirname -- "$HERMES_HOME")
if [[ ${home_parent##*/} == [Pp][Rr][Oo][Ff][Ii][Ll][Ee][Ss] ]]; then
HERMES_HOME=$(dirname -- "$home_parent")
fi
export HERMES_HOME
runtime="$HERMES_HOME/hermes-agent"
native_app="$runtime/apps/desktop/release/linux-unpacked"
runtime_ready() {
[[ -f $runtime/.hermes-bootstrap-complete && -f $runtime/venv/bin/hermes && -x $runtime/venv/bin/hermes && -f $runtime/venv/bin/python && -x $runtime/venv/bin/python ]] &&
timeout 15 "$runtime/venv/bin/hermes" --version >/dev/null 2>&1
}
check_main() {
local main_commit
main_commit=$(git -C "$runtime" rev-parse --verify refs/heads/main 2>/dev/null || true)
if [[ -n $main_commit && $main_commit != "$release_commit" && $main_commit != "$(git -C "$runtime" rev-parse --verify refs/remotes/origin/main 2>/dev/null)" ]]; then
echo "Hermes main has local commits. Keep that work and prepare the desktop with 'hermes desktop --build-only'." >&2
return 1
fi
}
if ! runtime_ready; then
# The upstream installer can reset an existing checkout. Do not pin a newer
# or modified runtime back to the package release while repairing setup.
if [[ -e $runtime || -L $runtime ]]; then
if [[ $(git -C "$runtime" rev-parse HEAD 2>/dev/null) != "$release_commit" ]] ||
[[ -n $(git -C "$runtime" status --porcelain --untracked-files=all) ]]; then
echo "Hermes setup is incomplete at $runtime. Repair that installation before trying again; existing files have been kept." >&2
exit 1
fi
check_main
fi
# Upstream replaces these commands, including foreign files and symlinks.
# Keep their original bytes/links before handing the names to the desktop.
command_backup=""
for command in hermes hermes-agent hermes-acp; do
command_path="$HOME/.local/bin/$command"
if [[ -e $command_path || -L $command_path ]]; then
if [[ ! -f $command_path && ! -L $command_path ]]; then
echo "Cannot replace $command_path: move it aside before installing Hermes Desktop." >&2
exit 1
fi
if [[ -z $command_backup ]]; then
command_backup=$(mktemp -d "$HOME/.local/bin/.hermes-before-desktop.XXXXXX")
echo "Saving existing Hermes commands in $command_backup"
fi
cp -a -- "$command_path" "$command_backup/"
fi
done
echo "Setting up the Hermes runtime..."
bash /usr/share/hermes-desktop/install.sh --skip-setup --branch main --commit "$release_commit" --force-commit --dir "$runtime" --hermes-home "$HERMES_HOME"
if ! runtime_ready; then
echo "Hermes runtime setup did not complete. Re-run this command after resolving the installer error." >&2
exit 1
fi
fi
runtime_commit=$(git -C "$runtime" rev-parse HEAD)
if [[ $runtime_commit == "$release_commit" ]]; then
# The updater switches to main before checking for changes. Start main at
# the packaged release, with enough history for its first fast-forward.
check_main
if [[ $(git -C "$runtime" rev-parse --is-shallow-repository) == "true" ]]; then
git -C "$runtime" fetch --unshallow origin main
fi
git -C "$runtime" switch -C main "$release_commit"
if git -C "$runtime" apply --check /usr/share/hermes-desktop/runtime.patch >/dev/null 2>&1; then
git -C "$runtime" apply /usr/share/hermes-desktop/runtime.patch
elif ! git -C "$runtime" apply --reverse --check /usr/share/hermes-desktop/runtime.patch >/dev/null 2>&1; then
echo "The Hermes Linux runtime patch conflicts with local changes. Existing files have been kept." >&2
exit 1
fi
fi
if [[ -e $native_app || -L $native_app ]]; then
if [[ ! -f $native_app/Hermes || ! -x $native_app/Hermes || ! -f $native_app/resources/app.asar || ! -f $native_app/resources/install-stamp.json ]]; then
echo "The Hermes desktop app at $native_app is incomplete. Repair it with 'hermes desktop --build-only' before trying again." >&2
exit 1
fi
else
if [[ $runtime_commit != "$release_commit" ]]; then
echo "The Hermes runtime has moved beyond the packaged desktop release. Run 'hermes desktop --build-only', then try again." >&2
exit 1
fi
desktop_changes=$(git -C "$runtime" status --porcelain --untracked-files=all -- apps/desktop package.json package-lock.json)
if [[ -n $desktop_changes ]]; then
echo "Hermes desktop sources have local changes. Run 'hermes desktop --build-only', then try again; existing files have been kept." >&2
exit 1
fi
mkdir -p -- "${native_app%/*}"
staging=$(mktemp -d "${native_app%/*}/.linux-unpacked.XXXXXX")
trap 'rm -rf -- "$staging"' EXIT
cp -a /opt/hermes-desktop/. "$staging/"
chmod 0755 "$staging/chrome-sandbox"
mv -T --no-clobber -- "$staging" "$native_app"
if [[ -e $staging ]]; then
echo "A Hermes desktop app appeared during setup. It has been kept; please try again." >&2
exit 1
fi
trap - EXIT
# Record this matching prebuilt app using the CLI's own content hash, so
# subsequent menu launches do not rebuild an app that is already current.
env -u PYTHONPATH -u PYTHONHOME "$runtime/venv/bin/python" - "$runtime" <<'PY'
import sys
from pathlib import Path
sys.path.insert(0, sys.argv[1])
if Path(sys.argv[1], "hermes_cli/main_desktop.py").is_file():
from hermes_cli.main_desktop import _write_desktop_build_stamp
else:
from hermes_cli.main import _write_desktop_build_stamp
_write_desktop_build_stamp(Path(sys.argv[1]), source_mode=False)
PY
fi
echo "Opening Hermes Desktop..."
setsid uwsm-app -- /usr/bin/hermes-desktop >/dev/null 2>&1 &
# Only a running Hermes can be told which skin to show; a unit outlives this
# terminal and reports to the journal.
echo "Matching Hermes to the current theme once it is set up..."
systemctl --user stop omarchy-hermes-theme.service 2>/dev/null || true
systemd-run --user --quiet --collect --unit=omarchy-hermes-theme omarchy-theme-set-hermes --wait
echo ""
echo "Hermes Desktop has been installed."
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
# omarchy:summary=Install the OpenClaw agent platform and its Control UI web app
# omarchy:requires-sudo=true
set -e
echo "Installing OpenClaw..."
omarchy-pkg-add openclaw
# The desktop app is OpenClaw's Control UI: a web app served by its own
# gateway. The launcher entry goes through omarchy-launch-openclaw, which
# onboards or starts that gateway before opening the window. The icon ships
# inside the package, so nothing is fetched here.
echo "Installing the OpenClaw web app..."
omarchy-webapp-install OpenClaw "http://127.0.0.1:18789" \
/usr/lib/node_modules/openclaw/dist/control-ui/apple-touch-icon.png \
omarchy-launch-openclaw
# A first install runs onboarding right here: launching the app instead would
# open a second floating terminal for the wizard, identical to this one.
# omarchy-openclaw-onboard runs the wizard the way this flow needs (terminal
# prompts, gateway as a user service, and it actually returns). A machine
# that is already onboarded goes straight to the app.
if [[ -f $HOME/.openclaw/openclaw.json ]]; then
echo "Opening OpenClaw..."
setsid uwsm-app -- gtk-launch OpenClaw >/dev/null 2>&1 &
elif omarchy-openclaw-onboard && [[ -f $HOME/.openclaw/openclaw.json ]]; then
echo "Opening OpenClaw..."
setsid uwsm-app -- gtk-launch OpenClaw >/dev/null 2>&1 &
fi
echo ""
echo "OpenClaw has been installed."
echo "If you skipped onboarding, launching OpenClaw from the app grid resumes it."
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# omarchy:summary=Install T3 Code and point it at the Omarchy palette
# omarchy:requires-sudo=true
set -e
T3CODE_HOME="${T3CODE_HOME:-$HOME/.t3}"
T3CODE_STATE_DIR="$T3CODE_HOME/userdata"
echo "Installing T3 Code..."
omarchy-pkg-add t3code-bin
echo "Matching T3 Code to the current theme..."
mkdir -p "$T3CODE_STATE_DIR"
# An updated user's current theme may have been staged before this template existed.
if [[ ! -f $HOME/.local/state/omarchy/current/theme/t3code.json ]]; then
omarchy-theme-refresh
fi
omarchy-theme-set-t3code
# The packaged CLI selects the published palette before the app's first launch.
t3 theme set omarchy --base-dir "$T3CODE_HOME"
echo "Opening T3 Code..."
setsid uwsm-app -- gtk-launch t3code >/dev/null 2>&1 &
echo ""
echo "T3 Code has been installed."
+7 -1
View File
@@ -16,6 +16,12 @@ fi
printf -v install_message '%q' "Installing ${name}..."
printf -v desktop_id_arg '%q' "$desktop_id"
# The list has to reach omarchy-pkg-add as several words, so each word is quoted
# rather than the whole string; -d '' reads past newlines and always ends at EOF.
read -r -d '' -a package_list <<<"$packages" || true
printf -v packages_arg '%q ' "${package_list[@]}"
packages_arg="${packages_arg% }"
# The subshell keeps & from backgrounding the package installation too.
exec omarchy-launch-floating-terminal-with-presentation \
"echo ${install_message}; omarchy-pkg-add ${packages} && (setsid uwsm-app -- gtk-launch ${desktop_id_arg} >/dev/null 2>&1 &)"
"echo ${install_message}; omarchy-pkg-add ${packages_arg} && (setsid uwsm-app -- gtk-launch ${desktop_id_arg} >/dev/null 2>&1 &)"
+10 -1
View File
@@ -12,4 +12,13 @@ if [[ -z $name || -z $packages ]]; then
exit 1
fi
exec omarchy-launch-floating-terminal-with-presentation "echo 'Installing ${name}...'; omarchy-pkg-add ${packages}"
printf -v install_message '%q' "Installing ${name}..."
# The list has to reach omarchy-pkg-add as several words, so each word is quoted
# rather than the whole string; -d '' reads past newlines and always ends at EOF.
read -r -d '' -a package_list <<<"$packages" || true
printf -v packages_arg '%q ' "${package_list[@]}"
packages_arg="${packages_arg% }"
exec omarchy-launch-floating-terminal-with-presentation \
"echo ${install_message}; omarchy-pkg-add ${packages_arg}"
+11 -17
View File
@@ -6,9 +6,10 @@
set -e
setup_policy_directory() {
sudo mkdir -p "$1"
sudo chmod a+rw "$1"
source "$OMARCHY_PATH/install/helpers/browser-policy.sh"
setup_chromium_policy_directory() {
browser_policy_setup_dir "$1"
}
announce_browser_installed() {
@@ -23,13 +24,6 @@ copy_chromium_flags() {
omarchy-install-chromium-ytdlp
}
setup_firefox_preferences() {
local distribution_dir="$1"
setup_policy_directory "$distribution_dir"
sudo cp -f "$OMARCHY_PATH/default/firefox/policies.json" "$distribution_dir/policies.json"
}
setup_firefox_wayland() {
mkdir -p ~/.config/environment.d
echo "MOZ_ENABLE_WAYLAND=1" > ~/.config/environment.d/omarchy-firefox-wayland.conf
@@ -40,7 +34,7 @@ chromium)
echo "Installing Chromium..."
omarchy-pkg-add chromium
setup_policy_directory /etc/chromium/policies/managed
setup_chromium_policy_directory /etc/chromium/policies/managed
copy_chromium_flags ~/.config/chromium-flags.conf
omarchy-theme-set-browser
announce_browser_installed "Chromium"
@@ -49,7 +43,7 @@ chrome)
echo "Installing Chrome..."
omarchy-pkg-aur-add google-chrome || exit 1
setup_policy_directory /etc/opt/chrome/policies/managed
setup_chromium_policy_directory /etc/opt/chrome/policies/managed
copy_chromium_flags ~/.config/chrome-flags.conf
omarchy-theme-set-browser
announce_browser_installed "Chrome"
@@ -58,7 +52,7 @@ edge)
echo "Installing Edge..."
omarchy-pkg-aur-add microsoft-edge-stable-bin || exit 1
setup_policy_directory /etc/opt/edge/policies/managed
setup_chromium_policy_directory /etc/opt/edge/policies/managed
copy_chromium_flags ~/.config/microsoft-edge-stable-flags.conf
omarchy-theme-set-browser
announce_browser_installed "Edge"
@@ -67,7 +61,7 @@ brave)
echo "Installing Brave..."
omarchy-pkg-aur-add brave-bin || exit 1
setup_policy_directory /etc/brave/policies/managed
setup_chromium_policy_directory /etc/brave/policies/managed
copy_chromium_flags ~/.config/brave-flags.conf
omarchy-theme-set-browser
announce_browser_installed "Brave"
@@ -76,7 +70,7 @@ brave-origin)
echo "Installing Brave Origin..."
omarchy-pkg-aur-add brave-origin-bin || exit 1
setup_policy_directory /etc/brave/policies/managed
setup_chromium_policy_directory /etc/brave/policies/managed
copy_chromium_flags ~/.config/brave-origin-flags.conf
omarchy-theme-set-browser
announce_browser_installed "Brave Origin"
@@ -85,7 +79,7 @@ firefox)
echo "Installing Firefox..."
omarchy-pkg-add firefox || exit 1
setup_firefox_preferences /usr/lib/firefox/distribution
browser_policy_setup_firefox_distribution /usr/lib/firefox/distribution
setup_firefox_wayland
announce_browser_installed "Firefox"
;;
@@ -93,7 +87,7 @@ zen)
echo "Installing Zen..."
omarchy-pkg-aur-add zen-browser-bin || exit 1
setup_firefox_preferences /opt/zen-browser/distribution
browser_policy_setup_firefox_distribution /opt/zen-browser/distribution
setup_firefox_wayland
announce_browser_installed "Zen"
;;
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
# omarchy:summary=Install the Claude extension for Chromium-based browsers
# omarchy:requires-sudo=true
set -euo pipefail
if (( EUID == 0 )); then
export PATH=/usr/bin:/bin
fi
EXTENSION_ID="fcoeoabgfenejglbffodgkkbkcdhcgfn"
EXTENSION_JSON='{ "external_update_url": "https://clients2.google.com/service/update2/crx" }'
PACKAGED_PATH=/usr/bin/omarchy-install-chromium-claude
# Brave and Brave Origin share Chromium's external extension directory.
# Seed all supported browsers, including those installed after choosing Claude.
EXTENSION_DIRS=(
/usr/share/chromium/extensions
/usr/share/google-chrome/extensions
/usr/share/microsoft-edge/extensions
)
installed=true
for dir in "${EXTENSION_DIRS[@]}"; do
if [[ ! -f $dir/$EXTENSION_ID.json ]] || [[ $(cat "$dir/$EXTENSION_ID.json") != "$EXTENSION_JSON" ]]; then
installed=false
fi
done
if [[ $installed == "true" ]]; then
exit 0
fi
if (( EUID == 0 )); then
for dir in "${EXTENSION_DIRS[@]}"; do
printf '%s\n' "$EXTENSION_JSON" | install -D -m 0644 /dev/stdin "$dir/$EXTENSION_ID.json"
done
elif [[ -t 0 ]]; then
exec sudo "$PACKAGED_PATH"
else
exec pkexec "$PACKAGED_PATH"
fi
+3
View File
@@ -16,6 +16,9 @@ browser_dirs=(
"$HOME/.config/BraveSoftware/Brave-Browser"
"$HOME/.config/BraveSoftware/Brave-Browser-Beta"
"$HOME/.config/BraveSoftware/Brave-Browser-Nightly"
"$HOME/.config/BraveSoftware/Brave-Origin"
"$HOME/.config/BraveSoftware/Brave-Origin-Beta"
"$HOME/.config/BraveSoftware/Brave-Origin-Nightly"
"$HOME/.config/microsoft-edge"
"$HOME/.config/microsoft-edge-dev"
)
+3
View File
@@ -17,6 +17,9 @@ browser_dirs=(
"$HOME/.config/BraveSoftware/Brave-Browser"
"$HOME/.config/BraveSoftware/Brave-Browser-Beta"
"$HOME/.config/BraveSoftware/Brave-Browser-Nightly"
"$HOME/.config/BraveSoftware/Brave-Origin"
"$HOME/.config/BraveSoftware/Brave-Origin-Beta"
"$HOME/.config/BraveSoftware/Brave-Origin-Nightly"
"$HOME/.config/microsoft-edge"
"$HOME/.config/microsoft-edge-dev"
)
+4 -31
View File
@@ -12,37 +12,10 @@ if [[ -z $1 ]]; then
fi
install_php() {
omarchy-pkg-add php composer php-sqlite xdebug
mise tool-alias set php github:nunomaduro/static-php-builds
mise use --global php@latest
# Install Path for Composer
if [[ :$PATH: != *:$HOME/.config/composer/vendor/bin:* ]]; then
echo 'export PATH="$HOME/.config/composer/vendor/bin:$PATH"' >>"$HOME/.bashrc"
source "$HOME/.bashrc"
echo "Added Composer global bin directory to PATH."
else
echo "Composer global bin directory already in PATH."
fi
# Enable some extensions
local php_ini_path="/etc/php/php.ini"
local extensions_to_enable=(
"bcmath"
"intl"
"iconv"
"openssl"
"pdo_sqlite"
"pdo_mysql"
)
# Enable Xdebug
sudo sed -i \
-e 's/^;zend_extension=xdebug.so/zend_extension=xdebug.so/' \
-e 's/^;xdebug.mode=debug/xdebug.mode=debug/' \
/etc/php/conf.d/xdebug.ini
for ext in "${extensions_to_enable[@]}"; do
sudo sed -i "s/^;extension=${ext}/extension=${ext}/" "$php_ini_path"
done
mise x php -- composer global config bin-dir "$HOME/.local/bin"
}
install_node() {
@@ -84,7 +57,7 @@ laravel)
echo -e "Installing PHP and Laravel...\n"
install_php
install_node
composer global require laravel/installer
mise x php -- composer global require laravel/installer
echo -e "\nYou can now run: laravel new myproject"
;;
symfony)
+5 -1
View File
@@ -13,5 +13,9 @@ if [[ -z $name || -z $package || -z $family ]]; then
exit 1
fi
printf -v install_message '%q' "Installing ${name}..."
printf -v package_arg '%q' "$package"
printf -v family_arg '%q' "$family"
exec omarchy-launch-floating-terminal-with-presentation \
"echo 'Installing ${name}...'; omarchy-pkg-add ${package} && sleep 2 && omarchy-font-set '${family}'"
"echo ${install_message}; omarchy-pkg-add ${package_arg} && sleep 2 && omarchy-font-set ${family_arg}"
+1 -1
View File
@@ -10,7 +10,7 @@ set -e
echo "Installing Xbox controller Bluetooth support..."
# Install xpadneo to ensure controllers work out of the box
omarchy-pkg-add linux-headers xpadneo-dkms
omarchy-pkg-add xpadneo-dkms
# Prevent xpad/xpadneo driver conflict
echo blacklist xpad | sudo tee /etc/modprobe.d/blacklist-xpad.conf >/dev/null
+280
View File
@@ -0,0 +1,280 @@
#!/bin/bash
# omarchy:summary=Install the Hermes CLI as a mise-backed wrapper in ~/.local/bin
# omarchy:args=[--check|--now|--owns|--remove]
# omarchy:examples=omarchy install hermes cli | omarchy install hermes cli --now
# Hermes pins every one of its dependencies exactly and declares
# Requires-Python >=3.11,<3.14, so it can neither be built against Arch's
# Python nor share the python-* packages. mise builds it a private environment
# instead.
#
# It gets its own installer rather than a line in omarchy-mise-install because
# of the interpreter pin. Given no compatible interpreter to hand, uv builds
# the venv against the system Python in violation of Hermes' own bound,
# reports success, and leaves the breakage to surface later inside a
# dependency -- and omarchy-mise-install writes a fixed stub with nowhere to
# say otherwise.
#
# There is only ever one Hermes on a machine. hermes-desktop cannot run against
# this one -- it needs a runtime built from its own commit, and the version gap
# fails its readiness probe -- so it installs its own under ~/.hermes and puts
# that on PATH. When the package is present it therefore owns Hermes outright:
# this installer stands aside and removes its own copy, so the terminal, the
# default agent and the app are all the same installation.
set -euo pipefail
mode=${1:-}
tool='pipx:hermes-agent[extras=all]'
python='3.13'
# The line that identifies the stub as this installer's; matched whole, so a
# wrapper that merely mentions the command is not mistaken for ours.
marker='# Written by omarchy-install-hermes-cli.'
# The package, not the runtime directory: it is installed before the app has
# ever run, and that is exactly when we must not start building a second copy.
desktop_owns_hermes() {
omarchy-pkg-present hermes-desktop
}
# The venv appears at the python-deps stage, several stages before the one that
# installs the command, so its presence says nothing about being usable. The
# marker is written last, and the command is what the agent actually runs.
desktop_hermes_ready() {
[[ -f $HOME/.hermes/hermes-agent/.hermes-bootstrap-complete ]] || return 1
# An executable of that name proves nothing about whose it is; the app's own
# points into ~/.hermes, and anything else is not the install we are asking
# about. Matched as a plain string, because the path carries a dot and an
# unanchored pattern would also claim a wrapper pointing at ~/xhermes.
[[ -f $HOME/.local/bin/hermes ]] || return 1
grep -qF "$HOME/.hermes" "$HOME/.local/bin/hermes" || return 1
# And a marker left behind by an install whose venv has since gone answers
# for nothing, so the command has to run, exactly as a foreign one must.
hermes_prompt_ready
}
# Whether Hermes is really installed, not merely whether the stub exists. A
# stub on its own is cold: running it installs Hermes, which takes minutes.
installed() {
[[ -d "$(mise where "$tool" 2>/dev/null)/hermes-agent/lib/python$python" ]]
}
# The stub is the only thing this installer owns. Anything else at that path
# -- Hermes' official installer, a hand-rolled wrapper, even a dangling link
# -- was put there by the user and is never deleted or overwritten here.
# Symlinks count as foreign even when they resolve to a marked file: the stub
# is written as a regular file, so a link is someone else's arrangement.
ours() {
[[ -f $HOME/.local/bin/hermes && ! -L $HOME/.local/bin/hermes ]] &&
grep -qxF "$marker" "$HOME/.local/bin/hermes"
}
foreign_hermes() {
[[ -e $HOME/.local/bin/hermes || -L $HOME/.local/bin/hermes ]] && ! ours
}
# A hermes at that path is usable when it is a command that runs: a regular
# executable whose --version answers. The executable bit alone proves little -- a directory
# passes -x on search permission, and a wrapper whose interpreter or target is
# gone passes it too. The desktop app applies the same probe with the same 15
# second budget, so what passes here is what it will use.
hermes_runs() {
[[ -f $HOME/.local/bin/hermes && -x $HOME/.local/bin/hermes ]] &&
timeout 15 "$HOME/.local/bin/hermes" --version >/dev/null 2>&1
}
# A flag counts only when the help defines it, not whenever it is mentioned:
# what follows must be a shape argparse prints after a definition -- the usage
# line's closing bracket, the gap before same-line help text, an uppercase
# metavar, or the end of the line. Prose like "With --tui: run ..." stays
# prose, and --tui-theme or --tui_mode never answers for --tui. Not probed by
# parsing an actual invocation on purpose: a release that ignores unknown
# arguments would turn the probe into a live session.
help_defines_flag() {
grep -qE -- "$1(]|[[:space:]][[:upper:]]|[[:space:]]{2}|$)" <<<"$2"
}
# Probed for the flags omarchy-agent actually passes -- --query to seed the
# session and --tui to keep it interactive -- rather than a marker standing in
# for them: the old probe keyed on chat carrying --oneshot, which no released
# Hermes did (it lived at the top level until v0.21 added chat's own), so
# every release read as "not ready".
hermes_prompt_ready() {
local help
hermes_runs &&
help=$(timeout 15 "$HOME/.local/bin/hermes" chat --help 2>/dev/null) &&
help_defines_flag '--tui' "$help" &&
help_defines_flag '--query' "$help"
}
# --owns answers whether the wrapper on PATH is the one this command wrote, so
# the migration and Remove Preinstalls do not each carry their own copy of the
# marker and drift from it.
if [[ $mode == "--owns" ]]; then
if ours; then exit 0; else exit 1; fi
fi
# --remove tears down a Hermes CLI this installer put in place -- the mise tool
# it installed and the stub it marked -- so Remove Hermes clears a CLI the app
# never superseded (an interrupted install, or the terminal CLI from before the
# app existed) rather than leaving it stranded on PATH. The whole teardown
# turns on the marked stub, exactly as replacement does further down: without
# it nothing proves the mise environment is Omarchy's rather than one the user
# built against the same spec, and a user's stays theirs. The desktop takeover
# removes the environment without asking, but that is its own bargain -- a
# second Hermes has to go whoever built it, and the app still provides the
# command afterwards; here nothing would. Idempotent: nothing owned, nothing
# to do.
if [[ $mode == "--remove" ]]; then
if ours; then
mise rm -g "$tool" >/dev/null 2>&1 || true
mise uninstall --all "$tool" >/dev/null 2>&1 || true
rm -f "$HOME/.local/bin/hermes" 2>/dev/null || true
# Every step is attempted before any is judged, and judged by what is left
# rather than by what the commands claimed: the marked stub still answering
# hermes, or mise still resolving the tool, is a CLI still installed no
# matter how the removal exited.
# Not "run --remove again": once the stub is gone nothing marks the mise
# environment as ours, so a rerun would find nothing it owns and succeed
# without touching what was left. Only the full commands finish the job.
if ours || mise where "$tool" >/dev/null 2>&1; then
echo "Could not remove the Hermes CLI Omarchy installed. Finish by hand:" >&2
echo " rm -f ~/.local/bin/hermes" >&2
echo " mise rm -g '$tool'" >&2
echo " mise uninstall --all '$tool'" >&2
exit 1
fi
fi
exit 0
fi
# --check lets callers tell a cold stub from a working one before they commit
# to a path that assumes Hermes is ready.
if [[ $mode == "--check" ]]; then
if desktop_owns_hermes; then
if desktop_hermes_ready; then exit 0; else exit 1; fi
fi
# A foreign command is ready only when it also supports prompted sessions;
# since it is not ours to replace, nothing this installer does will update it.
if foreign_hermes; then
if hermes_prompt_ready; then exit 0; else exit 1; fi
fi
if installed && hermes_prompt_ready; then exit 0; else exit 1; fi
fi
# Hand Hermes over to the app rather than keeping a second copy beside it.
if desktop_owns_hermes; then
# Not gated on that copy being healthy: `mise up` can rebuild it against the
# wrong interpreter and a half-finished install answers to neither test, and
# either way it is still a second Hermes. Removing nothing is harmless.
if mise where "$tool" >/dev/null 2>&1; then
echo "Hermes Desktop provides Hermes; removing the separate CLI install..." >&2
fi
mise rm -g "$tool" >/dev/null 2>&1 || true
mise uninstall --all "$tool" >/dev/null 2>&1 || true
# Our own stub has to go with it. Left in place it still answers `hermes`
# until the app's bootstrap overwrites it, and answering means building the
# second Hermes this whole arrangement exists to avoid.
if ours; then
rm -f "$HOME/.local/bin/hermes"
fi
if desktop_hermes_ready; then
exit 0
fi
echo "Hermes Desktop is installed but has not set Hermes up yet." >&2
echo "Launch Hermes Desktop once to finish installing it." >&2
exit 1
fi
# The user already has a hermes of their own. Leave it be: a working one is
# what the default agent will run, and a broken one is theirs to fix.
if foreign_hermes; then
if hermes_prompt_ready; then
exit 0
fi
if hermes_runs; then
echo "~/.local/bin/hermes does not support the interactive seeded sessions Omarchy needs." >&2
echo "Update it to a Hermes Agent release with interactive chat queries, then run omarchy-install-hermes-cli again." >&2
exit 1
fi
echo "~/.local/bin/hermes exists but is not runnable, and it was not installed by Omarchy." >&2
echo "Fix or remove it, then run omarchy-install-hermes-cli again." >&2
exit 1
fi
# Only the marked wrapper proves the matching mise environment is ours to replace.
if installed && ! hermes_prompt_ready; then
if ours; then
echo "Updating Hermes for prompted sessions..." >&2
mise rm -g "$tool" >/dev/null 2>&1 || true
mise uninstall --all "$tool" >/dev/null 2>&1 || true
else
echo "A Hermes mise environment exists without an Omarchy-owned wrapper." >&2
echo "Update or remove it explicitly, then run omarchy-install-hermes-cli again." >&2
exit 1
fi
fi
mkdir -p "$HOME/.local/bin"
rm -f "$HOME/.local/bin/hermes"
cat >"$HOME/.local/bin/hermes" <<EOF
#!/bin/bash
$marker
export UV_PYTHON="$python"
# Exported rather than set on the install line alone, so the version resolved
# to run agrees with the one just installed. Hermes ships several times a week
# and mise's cooldown would otherwise hold a new release back for days.
export MISE_MINIMUM_RELEASE_AGE=0
# mise up -- which omarchy update runs -- reinstalls without that pin, so this
# asks which interpreter is actually there rather than whether anything is.
if ! [[ -d "\$(mise where '$tool' 2>/dev/null)/hermes-agent/lib/python$python" ]]; then
echo "Installing Hermes on Python $python (this takes a minute)..." >&2
# mise's pipx backend shells out to uv, which a stock Omarchy does not have.
# It is fetched here rather than when this stub was written, so setting up a
# machine that never runs Hermes costs nothing.
if omarchy-cmd-missing uv && ! mise where uv >/dev/null 2>&1; then
mise use -g --quiet uv@latest || exit 1
fi
mise use -g --quiet --force '$tool' || exit 1
fi
# The pin belongs to building Hermes, not to everything Hermes then runs.
# Exported it would reach the agent and every command it shells out to, so a
# uv in the user's own project would resolve 3.13 there too -- uv only warns
# when that contradicts the project's requires-python, and builds it anyway.
exec env -u UV_PYTHON mise x '$tool' -- hermes "\$@"
EOF
chmod +x "$HOME/.local/bin/hermes"
# The desktop app resolves a hermes on PATH by running `hermes --version` with
# a 15 second budget, then falls back to cloning its own copy when that times
# out. A first-run mise install does not fit in 15 seconds, so anything that
# hands Hermes to the GUI has to install it here rather than leave it stubbed.
if [[ $mode == "--now" ]]; then
"$HOME/.local/bin/hermes" --version
if ! hermes_prompt_ready; then
echo "Hermes installed without the interactive seeded sessions Omarchy needs." >&2
exit 1
fi
fi
+29
View File
@@ -0,0 +1,29 @@
#!/bin/bash
# omarchy:summary=Ensure the OpenClaw CLI is installed for the default agent
# omarchy:args=[--check|--now]
# omarchy:requires-sudo=true
# OpenClaw is not a mise tool: the openclaw pacman package is the one OpenClaw
# installation on the machine — the CLI, the gateway, and the Install > AI web
# app all share it, and the fast ring keeps it current. The default-agent flow
# talks to that package through the same --check/--now contract mise-backed
# agents get from mise itself.
set -euo pipefail
case "${1:---now}" in
--check)
omarchy-pkg-present openclaw
;;
--now)
if ! omarchy-pkg-present openclaw; then
echo "Installing OpenClaw..."
omarchy-pkg-add openclaw
fi
;;
*)
echo "Usage: omarchy-install-openclaw-cli [--check|--now]" >&2
exit 1
;;
esac
+1 -1
View File
@@ -28,7 +28,7 @@ echo "Installing 1Password extension for Chromium..."
install_chromium_extension
echo "Opening 1Password..."
uwsm-app -- 1password >/dev/null 2>&1 &
setsid uwsm-app -- 1password >/dev/null 2>&1 &
echo ""
echo "1Password has been installed. Restart Chromium to load the browser extension."
+1
View File
@@ -1,6 +1,7 @@
#!/bin/bash
# omarchy:summary=Install and start the Dropbox service. Must then be authenticated via the web.
# omarchy:requires-sudo=true
echo "Installing all dependencies..."
omarchy-pkg-add dropbox dropbox-cli libappindicator-gtk3 python-gpgme nautilus-dropbox
+1 -1
View File
@@ -10,4 +10,4 @@ echo "Enabling ONCE background service..."
sudo systemctl enable --now once-background.service
echo -e "\nLaunching ONCE..."
once
sudo once
+4 -1
View File
@@ -4,8 +4,11 @@
set -e
# 1Password reads the display scale itself, the way Electron apps do, and comes
# up oversized next to every other window on a scaled monitor. The packaged
# .desktop pins it too; this covers the hotkey, which runs the binary directly.
if omarchy-cmd-present 1password; then
exec setsid uwsm-app -- 1password
exec setsid uwsm-app -- 1password --force-device-scale-factor=1
else
exec omarchy-launch-floating-terminal-with-presentation omarchy-install-service-1password
fi
+196 -13
View File
@@ -10,10 +10,50 @@
LOGO_FILE="$HOME/.config/omarchy/branding/about.txt"
FIT_FILE="$HOME/.local/state/omarchy/windows/about.fit"
OMARCHY_FASTFETCH_DIR=/etc/fastfetch
# A user-level fastfetch config can relocate or restyle the logo in ways this
# measurement cannot see, so leave sizing to the float rule in that case.
# The logo block in the fastfetch config. The fit below reproduces this layout to
# size the window, and the sheen has to repaint the very cells fastfetch drew the
# logo on, so both read the padding from here.
LOGO_PAD_LEFT=2
LOGO_PAD_TOP=2
LOGO_PAD_RIGHT=6
# The content is measured once and then goes on living: an uptime that turns
# minutes into hours and hours into days, a version string that grows, a module
# that shows up on the next boot. A window fitted to exactly what was measured
# has nowhere to put any of it, and the layout clips or scrolls the moment it
# grows — which is also the moment the logo stops being where it was drawn. Keep
# a little in hand rather than measure again every time something ticks over.
FIT_SPARE_COLUMNS=2
FIT_SPARE_ROWS=1
POLL_SECONDS=0.5
# fastfetch has no animation of its own, so the sheen is ours. It knows about a
# logo and nothing about About, which is why it is a file of its own.
source omarchy-branding-about-animation
# A user's own fastfetch config can relocate or restyle the logo in ways this
# measurement cannot see, so leave sizing to the float rule in that case. It can
# sit in any of several directories fastfetch searches ahead of Omarchy's own, so
# ask fastfetch for that order rather than keep a copy here for its next release
# to outdate.
custom_fastfetch_config() {
local directory listed=false
# A whole line at a time, because a home directory may contain a space, and the
# marker fastfetch puts beside the config it settled on is not part of the path.
while IFS= read -r directory; do
listed=true
directory=${directory% (\*)}
[[ ${directory%/} == "$OMARCHY_FASTFETCH_DIR" ]] && return 1
[[ -f ${directory%/}/config.jsonc ]] && return 0
done < <(fastfetch --list-config-paths 2>/dev/null)
# Silence is not the same answer as "none of them", so fall back to the
# directory fastfetch has always looked in first rather than read it as one.
[[ $listed == true ]] && return 1
[[ -f $HOME/.config/fastfetch/config.jsonc ]]
}
@@ -100,17 +140,22 @@ fit_window() {
# The guard character keeps command substitution from eating the trailing
# break line, which provides the bottom padding row.
local modules module_w module_h
local modules module_w
modules=$(fastfetch --logo none | sed 's/\x1b\[[0-9;?]*[a-zA-Z]//g'; printf X)
modules=${modules%X}
module_w=$(printf '%s' "$modules" | display_columns)
module_h=$(printf '%s' "$modules" | wc -l)
# Ask fastfetch how tall its layout came out rather than predicting it from the
# logo and the module column: once the logo is the taller of the two, fastfetch
# writes a row more than that arithmetic expects, and a window sized by it
# scrolls the top padding away.
measure_layout || return 1
# Mirror the logo block in the fastfetch config: 2 columns of padding left of
# the logo, 6 between logo and modules, 2 rows above it. Then 2 columns of
# right padding to match, and a row for the cursor so the trailing break shows.
local target_c=$(( 2 + logo_w + 6 + module_w + 2 ))
local target_r=$(( (logo_h + 2 > module_h ? logo_h + 2 : module_h) + 1 ))
# the logo, 6 between logo and modules. Then 2 columns of right padding to
# match, a row for the cursor so the trailing break shows, and the spare above.
local target_c=$(( LOGO_PAD_LEFT + logo_w + LOGO_PAD_RIGHT + module_w + LOGO_PAD_LEFT + FIT_SPARE_COLUMNS ))
local target_r=$(( LAYOUT_ROWS + 1 + FIT_SPARE_ROWS ))
local nudges=0 rows cols address width height shift_w shift_h target_w target_h
while :; do
@@ -146,17 +191,155 @@ fit_window() {
return 1
}
# One run answers both questions the sheen has to ask first. How tall the layout
# is, because a window too small for it scrolls, which moves the logo off the rows
# the frames address. And what colour fastfetch drew the logo in, because the
# glint has to hand every cell back in the colour it arrived in — assume it, and
# a logo fastfetch colours differently comes out of the first glint a new one.
measure_layout() {
[[ -n ${LAYOUT_ROWS:-} ]] && return 0
local rendered plain needle offset found row
# --pipe false because fastfetch drops its colours when it is not writing to a
# terminal, and it is writing to this substitution.
rendered=$(fastfetch --pipe false 2>/dev/null; printf X)
rendered=${rendered%X}
LAYOUT_ROWS=$(printf '%s' "$rendered" | wc -l)
(( LAYOUT_ROWS > 0 )) || return 1
# Find the logo in what fastfetch drew rather than working it out from the
# padding this file was written against. The config that runs is the one in
# /etc, which a checkout does not replace, so the two can disagree — and a logo
# measured two rows above where it was drawn is a logo the sheen moves. Not
# finding it at all is the same answer as finding it somewhere unexpected:
# whatever is on screen is not the text in the file, so leave it alone.
IFS=$'\t' read -r offset needle < <(logo_landmark) || return 1
[[ -n $needle ]] || return 1
plain=$(printf '%s' "$rendered" | sed 's/\x1b\[[0-9;?]*[a-zA-Z]//g')
found=$(printf '%s' "$plain" | LC_ALL=C.UTF-8 awk -v needle="$needle" \
'index($0, needle) { print NR, index($0, needle); exit }')
[[ -n $found ]] || return 1
read -r LOGO_ROW LOGO_COLUMN <<<"$found"
LOGO_ROW=$(( LOGO_ROW - offset ))
(( LOGO_ROW >= 1 && LOGO_COLUMN >= 1 )) || return 1
# Whatever fastfetch set before that row is what the sheen has to give back.
row=$(printf '%s' "$rendered" | sed -n "$(( LOGO_ROW + offset ))p")
LOGO_COLOR=""
[[ $row =~ ^(($ESC\[[0-9;]*m)+) ]] && LOGO_COLOR=${BASH_REMATCH[1]}
return 0
}
# The longest line of the logo, and how far down the logo it sits — the most
# distinctive thing to look for in the render, and the offset that turns where it
# was found back into where the logo starts.
# Tab-separated, and the offset first, because the line may hold spaces of its
# own and splitting on them would cut the landmark short.
logo_landmark() {
LC_ALL=C.UTF-8 awk '
{ if (length($0) > best) { best = length($0); line = $0; at = NR - 1 } }
END { if (best > 0) printf "%d\t%s\n", at, line }
' "$LOGO_FILE" 2>/dev/null
}
# The About screen's own reasons the logo might not be where these frames would
# draw it. Whether the logo itself can be animated is the sheen's own question.
build_sheen() {
# Whatever the last build left is not this window's, and the loop below plays
# whatever is here — so a build that fails has to leave nothing to play.
SHEEN_FRAMES=()
custom_fastfetch_config && return 1
# fastfetch honours NO_COLOR when it writes to a terminal but not when it writes
# to the measurement below, so a logo drawn without colour would be measured as
# green and left green by the first glint. A glint is colour anyway, which is
# the thing NO_COLOR asks for none of.
[[ -n ${NO_COLOR:-} ]] && return 1
measure_layout || return 1
# The layout needs a row for the cursor past its last line. Without one it has
# scrolled, and the logo is no longer on the rows the frames address. Ask the
# terminal where the cursor actually is rather than trust the arithmetic, and
# keep the arithmetic for a terminal that will not say.
local rows cols
read -r rows cols <<<"$(stty size)"
(( rows > LAYOUT_ROWS )) || return 1
# The cell the logo's first row starts on, every attribute fastfetch left on
# those cells so a glint that has passed leaves them as it found them, and the
# room it has to work in left of the module column.
sheen_build "$LOGO_FILE" "$LOGO_ROW" "$LOGO_COLUMN" "${ESC}[0m${LOGO_COLOR}" "$(( cols - LOGO_COLUMN + 1 ))"
}
# What the frames were built against. A window that resized, or a logo that was
# rebranded, needs fastfetch run again before anything is drawn over it.
content_changed() {
[[ $resized == true ]] && return 0
[[ $(stty size) != "$grid" || $(stat -c %Y "$LOGO_FILE" 2>/dev/null) != "$logo_stamp" ]]
}
# A tick either times out, which is the delay, or a key arrives and About closes.
# Anything else on stdin is a terminal that went away, which closes it too.
tick() {
read -t "$1" -n 1 -s && exit
(( $? > 128 )) || exit
}
play_sheen() {
local index
for (( index = 0; index < ${#SHEEN_FRAMES[@]}; index++ )); do
# Stop before painting a frame rather than after: a resize has already moved
# the cells these address, and the rest of a sweep would land across them.
# The grid costs a process, so it stays on the poll interval. The trap costs
# nothing, so it is read last — a signal that arrived while the grid was being
# read would otherwise be seen only after another frame had gone out.
(( index % SHEEN_POLL_FRAMES == 0 )) && content_changed && return 1
[[ $resized == true ]] && return 1
printf '%s' "${SHEEN_FRAMES[index]}"
tick "$SHEEN_FRAME_SECONDS"
done
return 0
}
# The logo is still between glints, so About is a quiet window to leave open.
rest_sheen() {
local ticks
for (( ticks = 0; ticks < SHEEN_REST_TICKS; ticks++ )); do
tick "$POLL_SECONDS"
content_changed && return 1
done
return 0
}
if [[ ${1:-} == "--render" ]]; then
printf '\e[?25l'
# A sweep runs for seconds between polls, so it reads this instead. The polling
# stays as the backstop, for a signal that arrived while it could not be taken.
resized=false
trap 'resized=true' WINCH
# Give the compositor a moment to apply the window rules before measuring cells.
settle_grid
fitted=false
passes=0
while :; do
size=$(stty size)
grid=$(stty size)
logo_stamp=$(stat -c %Y "$LOGO_FILE" 2>/dev/null)
resized=false
LAYOUT_ROWS=""
clear
fastfetch
# A second pass picks up a fit that could not measure the window the first
@@ -165,10 +348,10 @@ if [[ ${1:-} == "--render" ]]; then
if [[ $fitted == false ]] && (( ++passes <= 2 )); then
fit_window && fitted=true
fi
while [[ $(stty size) == $size && $(stat -c %Y "$LOGO_FILE" 2>/dev/null) == $logo_stamp ]]; do
read -t 0.5 -n 1 -s && exit
(( $? > 128 )) || exit
done
# An empty frame list plays nothing, so a logo that cannot be animated waits
# here exactly as the still one did, and there is one loop rather than two.
build_sheen
while play_sheen && rest_sheen; do :; done
# A rebranded logo changes the content dimensions, so measure again.
if [[ $(stat -c %Y "$LOGO_FILE" 2>/dev/null) != $logo_stamp ]]; then
fitted=false
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# omarchy:summary=Open the Docker TUI (lazydocker) with access to the Docker daemon
# omarchy:hidden=true
# By default the install user is NOT in the docker group: membership is
# root-equivalent (a container can bind-mount / and rewrite the host as root),
# so a single process running as the user could otherwise escalate to root with
# no prompt. lazydocker needs the root-owned Docker socket, so when the group is
# absent, gate that access behind a polkit prompt. If the user has opted into
# sudoless Docker (omarchy-setup-security-sudoless-docker), the socket is already
# reachable, so run lazydocker directly — omarchy-sudo-docker answers that for
# this session, so the prompt stays until the reboot that grants the group.
# pkexec sanitizes the environment, so carry TERM through for the TUI to render
# and run lazydocker from root's PATH.
if omarchy-sudo-docker; then
exec pkexec /usr/bin/env TERM="${TERM:-xterm-256color}" lazydocker
else
exec lazydocker
fi
+6 -3
View File
@@ -18,17 +18,20 @@ else
editor="nvim"
fi
# sublime-text-4 ships `subl` on PATH, not `sublime_text`.
[[ $editor == sublime_text ]] && editor=subl
omarchy-cmd-present "$editor" || editor="nvim"
case "${editor##*/}" in
nvim | vim | nano | micro | hx | helix | fresh)
if [[ $inline == "true" ]]; then
exec "$editor" "$@"
exec "$editor" -- "$@"
else
exec omarchy-launch-tui "$editor" "$@"
exec omarchy-launch-tui "$editor" -- "$@"
fi
;;
*)
exec setsid uwsm-app -- "$editor" "$@"
exec setsid uwsm-app -- "$editor" -- "$@"
;;
esac
@@ -8,6 +8,7 @@
source omarchy-restart-gum
cmd="$*"
presentation_script="omarchy-show-logo; $cmd; if (( \$? != 130 )); then omarchy-show-done; fi"
# 130 is the user bailing with Ctrl-C; anything else gets the Done/Failed prompt.
presentation_script="omarchy-show-logo; $cmd; code=\$?; if (( code != 130 )); then omarchy-show-done \$code; fi"
exec setsid uwsm-app -- xdg-terminal-exec --app-id=org.omarchy.terminal --title=Omarchy -e bash -c "$presentation_script"
+91
View File
@@ -0,0 +1,91 @@
#!/bin/bash
# omarchy:summary=Open the OpenClaw Control UI (or its terminal UI with --tui), onboarding or starting the gateway first when needed.
# omarchy:args=[--tui [--message <text>]]
set -euo pipefail
tui=false
message=()
if [[ ${1:-} == "--tui" ]]; then
tui=true
shift
if [[ ${1:-} == "--message" ]]; then
message=(--message "${2:?--message needs a value}")
shift 2
fi
fi
# Onboarding is OpenClaw's own interactive wizard, run through
# omarchy-openclaw-onboard so it stays in the terminal, installs the gateway
# as a user service, and returns (see that script for why bare `openclaw
# onboard` does none of those as of 2026.9.1). The config check gates what
# follows because the wizard's "Skip for now" also exits 0: only inference
# that passed writes the config, and skipping must not loop back into the
# wizard.
if [[ ! -f $HOME/.openclaw/openclaw.json ]]; then
if [[ $tui == "true" ]]; then
# Already in a terminal, so the wizard runs right here.
omarchy-openclaw-onboard
[[ -f $HOME/.openclaw/openclaw.json ]] || exit 1
else
# The wizard needs a real terminal, and chaining the relaunch means
# finishing it lands the user in the app. Single quotes so $HOME expands
# in the spawned terminal.
# shellcheck disable=SC2016
exec omarchy-launch-floating-terminal-with-presentation \
'omarchy-openclaw-onboard && [[ -f $HOME/.openclaw/openclaw.json ]] && omarchy-launch-openclaw'
fi
fi
dashboard_url() {
# browserUrl carries a single-use browser handoff; url is the shared-auth
# fallback for gateways predating the handoff flow. The timeout keeps a
# wedged CLI from hanging an app-grid launch that has no terminal to ^C.
timeout 10 openclaw dashboard --json 2>/dev/null | jq -re '.browserUrl // .url // empty'
}
# --json never starts the gateway, so an empty answer means it is not running.
# Recovery goes through the gateway's own service commands. `dashboard --yes`
# used to be the start/install-without-prompting path, but as of 2026.9.1 it
# defers to "the owning supervisor" for both a missing and a stopped unit,
# and once the gateway is up it copies a one-time pairing URL into the
# clipboard, which nothing here needs. Enablement, not the unit file, decides:
# a unit that was written but never enabled (an install that died halfway)
# would otherwise be "started" once and stay off at every following login,
# where --force rewrites and enables it.
if ! url=$(dashboard_url); then
if systemctl --user is-enabled --quiet openclaw-gateway.service 2>/dev/null; then
timeout 60 openclaw gateway start >&2 || true
else
timeout 120 openclaw gateway install --force >&2 || true
fi
# A first-ever service install (unit write, daemon-reload, first boot)
# takes materially longer than starting an installed unit, so the budget
# is sized for the slow case.
for _ in {1..30}; do
url=$(dashboard_url) && break
sleep 1
done
fi
if [[ -z ${url:-} ]]; then
echo "OpenClaw's gateway did not come up. Check it with: openclaw gateway status" >&2
echo "If onboarding never finished, rerun it with: omarchy-openclaw-onboard" >&2
exit 1
fi
if [[ $tui == "true" ]]; then
# Attach to the gateway rather than `openclaw chat`: chat is the embedded
# local runtime, which refuses to start while the gateway owns ~/.openclaw's
# state directory -- and on any machine set up through Install > AI, the
# gateway service is running whenever the desktop session is.
exec openclaw tui "${message[@]}"
fi
# The handoff URL rides in the browser's argv, which uwsm's app daemon echoes
# into the user journal. Accepted: the token is single-use with a ten-minute
# expiry against a loopback-only gateway, and journal access already implies
# access to ~/.openclaw itself.
exec omarchy-launch-webapp "$url"
+73 -16
View File
@@ -75,8 +75,9 @@ fi
selection_file=$(mktemp)
done_file=$(mktemp)
pending_file=$(mktemp)
pending_video_file=$(mktemp)
rm -f "$done_file"
trap 'rm -f "$selection_file" "$done_file" "$pending_file"' EXIT
trap 'rm -f "$selection_file" "$done_file" "$pending_file" "$pending_video_file"' EXIT
image_dirs_env=""
for dir in "${image_dirs[@]}"; do
@@ -111,8 +112,8 @@ cache_key=$(printf '%s' "$image_dirs_env" | md5sum | cut -d ' ' -f 1)
rows_cache_file="$cache_dir/$cache_key.rows"
rows_signature_file="$cache_dir/$cache_key.signature"
rows_fast_signature_file="$cache_dir/$cache_key.fast-signature"
rows_signature="v3"$'\n'
rows_fast_signature="v2"$'\n'
rows_signature="v4"$'\n'
rows_fast_signature="v3"$'\n'
rows_cacheable=true
rows_cache_hit=false
image_files=()
@@ -133,17 +134,25 @@ else
image_files+=("$image")
image_signature=$(stat -Lc '%s:%Y' "$image") || continue
rows_signature+="$image:$image_signature"$'\n'
done < <(find -L "$dir" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) -print0 2>/dev/null | sort -z)
done < <(find -L "$dir" -maxdepth 1 -type f \
\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \
-o -iname '*.mp4' -o -iname '*.m4v' -o -iname '*.mov' -o -iname '*.webm' -o -iname '*.mkv' -o -iname '*.avi' \) \
-print0 2>/dev/null | sort -z)
fi
done
fi
is_video_path() {
[[ ${1,,} =~ \.(mp4|m4v|mov|webm|mkv|avi)$ ]]
}
generate_thumbnail() {
local image="$1"
local thumbnail="$2"
local lock="$thumbnail.lock"
local lock_fd
local tmp="$thumbnail.$$.jpg"
local thumbnail_command
# Older releases used directories as locks, which could survive a killed
# generator and block this thumbnail forever. Only reap aged ones, so a
@@ -161,13 +170,29 @@ generate_thumbnail() {
[[ -f $thumbnail ]] && return
# Callers fan out one generator per image, so keep each vips single-threaded.
# Close the lock fd for vips: an orphaned or hung vips must not keep holding
# the lock after this shell is killed.
if VIPS_CONCURRENCY=1 vipsthumbnail "$image" --size 1536x864 --smartcrop=centre --path "$tmp[Q=82,strip]" {lock_fd}>&-; then
# Callers fan out one generator per file, so keep each image conversion
# single-threaded. ffmpegthumbnailer provides a still preview for videos.
if is_video_path "$image"; then
# Videos are generated before the picker opens, so one unreadable or
# stalled file must not hold it shut. A failed run drops the row.
thumbnail_command=(timeout -k 5 10 ffmpegthumbnailer -i "$image" -o "$tmp" -s 1536 -q 8)
else
thumbnail_command=(env VIPS_CONCURRENCY=1 vipsthumbnail "$image" --size 1536x864 --smartcrop=centre --path "$tmp[Q=82,strip]")
fi
# Close the lock fd for the converter: an orphaned or hung child must not
# keep holding the lock after this shell is killed.
if "${thumbnail_command[@]}" {lock_fd}>&-; then
mv -f "$tmp" "$thumbnail"
else
status=$?
rm -f "$tmp" "$thumbnail"
# Remember a video the converter rejected, so it costs nothing on the next
# open. The key covers size and mtime, so a repaired file starts clean. A
# timeout is left to retry: the machine may only have been busy.
if is_video_path "$image" && (( status != 124 && status != 137 )); then
: >"$thumbnail.failed"
fi
fi
}
@@ -186,7 +211,17 @@ thumbnail_for() {
thumbnail="$cache_dir/$hash.jpg"
if [[ ! -f $thumbnail ]]; then
if [[ $lazy_thumbnails == true && $cache_only != true ]]; then
# A video that already failed to convert has no row to offer. Hand the
# marker back so the caller can keep the rows uncached over its absence.
if is_video_path "$image" && [[ -f $thumbnail.failed ]]; then
printf '%s' "$thumbnail.failed"
return
fi
# A lazy row stands in with the media file itself, which the picker draws
# with an Image -- fine for a picture, blank for a video. Videos take the
# queue instead, which also keeps them under its narrower fan out.
if [[ $lazy_thumbnails == true && $cache_only != true ]] && ! is_video_path "$image"; then
rows_cacheable=false
if [[ $prepare_only != true ]]; then
@@ -197,19 +232,35 @@ thumbnail_for() {
return
fi
printf '%s\0%s\0' "$image" "$thumbnail" >>"$pending_file"
if is_video_path "$image"; then
printf '%s\0%s\0' "$image" "$thumbnail" >>"$pending_video_file"
else
printf '%s\0%s\0' "$image" "$thumbnail" >>"$pending_file"
fi
fi
printf '%s' "$thumbnail"
}
# Generate every queued thumbnail at once; each vips run is single-threaded.
# Each vips run is single-threaded, so still images can fill every core.
# ffmpegthumbnailer leaves FFmpeg's automatic threading on, so a full-width fan
# out of those would put a codec thread pool on every core at once.
drain_pending_thumbnails() {
[[ -s $pending_file ]] || return 0
local video_jobs
export -f generate_thumbnail
xargs -a "$pending_file" -0 -n 2 -P "$(nproc)" \
bash -c 'generate_thumbnail "$1" "$2"' _ >/dev/null 2>&1 || true
export -f generate_thumbnail is_video_path
if [[ -s $pending_file ]]; then
xargs -a "$pending_file" -0 -n 2 -P "$(nproc)" \
bash -c 'generate_thumbnail "$1" "$2"' _ >/dev/null 2>&1 || true
fi
if [[ -s $pending_video_file ]]; then
video_jobs=$(( $(nproc) / 4 ))
(( video_jobs > 0 )) || video_jobs=1
xargs -a "$pending_video_file" -0 -n 2 -P "$video_jobs" \
bash -c 'generate_thumbnail "$1" "$2"' _ >/dev/null 2>&1 || true
fi
}
if [[ $rows_cache_hit != true && -f $rows_cache_file && -f $rows_signature_file ]] && cmp -s "$rows_signature_file" <(printf '%s' "$rows_signature"); then
@@ -219,6 +270,12 @@ elif [[ $rows_cache_hit != true ]]; then
for image in "${image_files[@]}"; do
thumbnail=$(thumbnail_for "$image")
[[ -n $thumbnail ]] || continue
# Cached rows are trusted on the directory's mtime alone, which a file
# repaired in place never changes. Leave them uncached instead.
if [[ $thumbnail == *.failed ]]; then
rows_cacheable=false
continue
fi
if [[ $lazy_thumbnails == true && $cache_only != true && $thumbnail == $image ]]; then
rows_cacheable=false
fi
@@ -232,7 +289,7 @@ elif [[ $rows_cache_hit != true ]]; then
drain_pending_thumbnails
if [[ -s $pending_file ]]; then
if [[ -s $pending_file || -s $pending_video_file ]]; then
pruned=""
while IFS=$'\t' read -r row_image row_thumbnail; do
if [[ ! -e $row_thumbnail ]]; then
+76 -4
View File
@@ -38,6 +38,12 @@ parse_keycodes() {
if (code != "" && symbol != "" && symbol != "NoSymbol") keycode_symbol[code] = toupper(symbol)
}
# Read off the keysym rather than the keycode, so the key left of 1 reads
# as the symbol printed on it whichever key the active layout puts it on.
for (code in keycode_symbol) {
if (keycode_symbol[code] == "GRAVE") keycode_symbol[code] = "~"
}
mouse_symbol["272"] = "LEFT MOUSE BUTTON"
mouse_symbol["273"] = "RIGHT MOUSE BUTTON"
mouse_symbol["274"] = "MIDDLE MOUSE BUTTON"
@@ -305,6 +311,7 @@ dynamic_bindings() {
case "$key" in
comma) key="COMMA" ;;
grave) key="~" ;;
period) key="PERIOD" ;;
minus) key="MINUS" ;;
equal) key="EQUAL" ;;
@@ -324,15 +331,41 @@ static_bindings() {
echo "SHIFT ALT,D,Download Video from Web App,sendshortcut,SHIFT ALT,D,"
}
# Actions Omarchy binds to a second chord meant as an alternative, named one at
# a time. A rule would be wrong here: Alt + Tab and Shift + Alt + Tab both say
# "Reveal active window on top" while cycling opposite ways, and a media key is
# nobody's idea of an alternative to a Super chord. Only chords a user reaches
# for interchangeably belong on one row.
alternative_chord_actions() {
cat <<'ACTIONS'
Close window
Calculator
Toggle scratchpad
Move window to scratchpad
ACTIONS
}
# Parse and format keybindings
#
# `awk` does the heavy lifting:
# - Set the field separator to a comma ','.
# - Joins the key combination (e.g., "SUPER + Q").
# - Joins the command that the key executes.
# - Puts an action's alternative chord on the row with the first one.
# - Prints display text and dispatch metadata as tab-separated fields.
parse_binding_records() {
awk -F, '
awk -F, -v alternatives="$(alternative_chord_actions)" '
BEGIN {
# The column every row pads its chords to. Nothing is allowed past it: the
# menu renders in monospace, and a row that overruns pushes its arrow out
# of a column the eye reads straight down.
column = 35;
split(alternatives, named, "\n");
for (i in named) {
if (named[i] != "") shares_a_row[named[i]] = 1;
}
}
{
# Combine the modifier and key (first two fields)
key_combo = $1 " + " $2;
@@ -365,8 +398,35 @@ parse_binding_records() {
gsub(/[ \t]+/, " ", key_combo); # Collapse multiple spaces to one
}
# An alternative chord joins the row the first one opened: "SUPER + W /
# SUPER + Q -> Close window". Both halves have to agree on what they run,
# since a label is only what a chord is called; an unresolved dispatcher
# says nothing at all, so it never counts as agreement.
if (action != "") {
printf "%-35s → %s\t%s\t%s\n", key_combo, action, dispatcher, arg;
action_key = action SUBSEP dispatcher SUBSEP arg;
together = "";
if (action in shares_a_row && dispatcher != "" && action_key in leads) {
together = chords[leads[action_key]] " / " key_combo;
}
if (together != "" && length(together) <= column) {
chords[leads[action_key]] = together;
} else {
# Also the path a pair too wide for the column takes: two rows in
# line beat one that juts out of it.
entries++;
chords[entries] = key_combo;
actions[entries] = action;
dispatchers[entries] = dispatcher;
args[entries] = arg;
leads[action_key] = entries;
}
}
}
END {
for (entry = 1; entry <= entries; entry++) {
printf "%-*s → %s\t%s\t%s\n", column, chords[entry], actions[entry], dispatchers[entry], args[entry];
}
}'
}
@@ -374,7 +434,13 @@ parse_binding_records() {
prioritize_entries() {
awk -F '\t' '
{
# Alternative chords are display only. Classifying on them would read
# "SUPER SHIFT + RETURN / SUPER SHIFT + B" as the alternate browser chord
# and drop the browser 20 rows down its own list, so match the chord that
# leads the row and ignore the rest.
line = $1
sub(/ \/ [^→]*→/, " →", line)
prio = 50
if (match(line, /Keybindings/)) prio = 0
if (match(line, /Omarchy menu/)) prio = 1
@@ -436,6 +502,12 @@ prioritize_entries() {
cut -f2-
}
# Drop repeated records while keeping the order Hyprland reported them in, so
# the chord a user declared first is the one that leads a merged entry.
dedupe_binding_records() {
awk '!seen[$0]++'
}
output_binding_records_uncached() {
local dynamic
@@ -446,7 +518,7 @@ output_binding_records_uncached() {
[[ -n $dynamic ]] && printf '%s\n' "$dynamic"
static_bindings
} |
sort -u |
dedupe_binding_records |
parse_keycodes |
parse_binding_records |
prioritize_entries
@@ -458,7 +530,7 @@ output_binding_records_uncached() {
keybindings_cache_key() {
{
printf 'v11\n'
printf 'v13\n'
hyprctl devices 2>/dev/null | grep -F 'active keymap:'
hyprctl binds 2>/dev/null
} | sha256sum | awk '{ print $1 }'
+3 -3
View File
@@ -85,16 +85,16 @@ wait_for_pacman_transaction
mkdir -p "$STATE_DIR"
[[ -d $MIGRATIONS_DIR ]] || exit 0
while IFS=$'\t' read -r name file marker; do
while IFS=$'\t' read -r name file marker <&3; do
[[ -n $name ]] || continue
if [[ ! -f $marker ]]; then
echo -e "\e[32m\nRunning migration (${name%.sh})\e[0m"
OMARCHY_PATH="$OMARCHY_PATH" bash -euo pipefail "$file"
OMARCHY_PATH="$OMARCHY_PATH" bash -euo pipefail "$file" 3<&-
mkdir -p "$(dirname "$marker")"
touch "$marker"
fi
done < <(migration_entries)
done 3< <(migration_entries)
# Clear a login-time notification the user left sitting there and then resolved
# by running migrations some other way. The substring matches both the current
+1 -1
View File
@@ -41,7 +41,7 @@ fi
# The shell keeps the click command with the toast, so this oneshot can hand the
# invitation over and exit instead of staying activated until it is answered.
omarchy-notification-send -u critical -g  "Pending Omarchy Migrations" "$message" \
--exec "omarchy-launch-floating-terminal-with-presentation omarchy-migrate" && exit 0
--exec omarchy-launch-floating-terminal-with-presentation omarchy-migrate && exit 0
# Reached when the notification could not be handed off, so fall back to telling
# the user in the terminal.
+32 -2
View File
@@ -12,8 +12,38 @@ package=$1
command=${2:-$1}
bin=${3:-$command}
# The command name becomes a file name under ~/.local/bin, so a slash in it
# writes the wrapper somewhere else and the rm below deletes somewhere else. A
# leading dot hides it or walks up, and a leading dash makes a name that reads
# as an option to whatever picks it up. Checked before anything is removed or
# written, and kept to those shapes so package names like npm:playwright still
# stand in for the command name.
case "$command" in
*/* | .* | -* | *[[:cntrl:]]*)
echo "omarchy-mise-install: '$command' is not usable as a command name" >&2
exit 1
;;
esac
mkdir -p "$HOME/.local/bin"
# The heredoc below is unquoted, so whatever these hold is written into the
# wrapper as shell source. Quote them the way omarchy-install-and-launch does, so
# a package name carrying shell characters stays one argument instead of running.
printf -v package_arg '%q' "$package"
printf -v bin_arg '%q' "$bin"
# Keep values that are inert inside double quotes in the form existing migrations
# recognize, including mise backend options that %q would unnecessarily escape.
case "$package" in
*'$'* | *'`'* | *'"'* | *'\'* | *'!'* | *[[:cntrl:]]*) ;;
*) package_arg="\"$package\"" ;;
esac
case "$bin" in
*'$'* | *'`'* | *'"'* | *'\'* | *'!'* | *[[:cntrl:]]*) ;;
*) bin_arg="\"$bin\"" ;;
esac
# These tools install and upgrade on first run, so mise's release cooldown would
# hold a new version back for days after it ships. Exported rather than set on
# the install line alone, so resolving the version to execute agrees with the
@@ -22,8 +52,8 @@ rm -f "$HOME/.local/bin/$command"
cat >"$HOME/.local/bin/$command" <<EOF
#!/bin/bash
export MISE_MINIMUM_RELEASE_AGE=0
mise use -g --quiet "$package" || exit 1
exec mise x "$package" -- "$bin" "\$@"
mise use -g --quiet $package_arg || exit 1
exec mise x $package_arg -- $bin_arg "\$@"
EOF
chmod +x "$HOME/.local/bin/$command"
+3 -1
View File
@@ -22,7 +22,9 @@ if [[ -z $interface ]]; then
# menu's visibility gate describe. Fall back to the first connected Wi-Fi
# device. nmcli localizes state names, so pin the locale, and the prefix
# match accepts states like "connected (externally)".
route_device=$(ip route get 1.1.1.1 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i == "dev") { print $(i + 1); exit } }')
if ! route_device=$(ip route get 1.1.1.1 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i == "dev") { print $(i + 1); exit } }'); then
route_device=""
fi
if [[ -n $route_device && -d /sys/class/net/$route_device/wireless ]]; then
interface=$route_device
else
+151 -63
View File
@@ -1,7 +1,7 @@
#!/bin/bash
# omarchy:summary=Send an Omarchy desktop notification
# omarchy:args=[--exec <command>] [--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [--image <path-or-uri>] <headline> [description] [notify-send options]
# omarchy:args=[--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [-i <icon>] [-t <ms>] [-r <id>] [-p] [--image <path-or-uri>] <headline> [description] [--exec <program> [args...]]
# omarchy:examples=omarchy notification send "Reminder" "5 minutes are up" -g 󰢌
set -euo pipefail
@@ -11,61 +11,75 @@ description=""
glyph=
urgency="low"
app_name="omarchy-action"
app_icon=""
image=
exec_command=
args=()
expire_timeout=-1
replaces_id=0
print_id=0
exec_args=()
exec_present=0
parsed_option_args=0
usage() {
echo "Usage: omarchy-notification-send [--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [-i <icon>] [-t <ms>] [-r <id>] [-p] [--image <path-or-uri>] <headline> [description] [--exec <program> [args...]]" >&2
}
# Recognize a known option, in both `--flag value` and `--flag=value` forms.
# Returns 1 for anything unrecognized so the caller can decide (headline, or a
# hard error in option position).
parse_omarchy_option() {
case $1 in
-g | --glyph)
if (($# < 2)); then
echo "Missing value for $1" >&2
exit 1
fi
glyph=$2
parsed_option_args=2
local opt val nargs
if [[ $1 == --?*=* ]]; then
opt=${1%%=*}
val=${1#*=}
nargs=1
else
opt=$1
val=${2-}
nargs=2
fi
# -p/--print-id is a flag; it takes no value.
if [[ $opt == -p || $opt == --print-id ]]; then
print_id=1
parsed_option_args=1
return 0
fi
case $opt in
-g | --glyph | -u | --urgency | --app-name | -i | --icon | --image | -r | --replace-id | -t | --expire-time) ;;
*) return 1 ;;
esac
if ((nargs == 2)) && (($# < 2)); then
echo "Missing value for $opt" >&2
exit 1
fi
case $opt in
-g | --glyph) glyph=$val ;;
-u | --urgency) urgency=$val ;;
--app-name) app_name=$val ;;
-i | --icon) app_icon=$val ;;
--image) image=$val ;;
-r | --replace-id)
[[ $val =~ ^[0-9]+$ ]] || {
echo "Invalid $opt value (numeric id expected): $val" >&2
exit 1
}
replaces_id=$val
;;
-u | --urgency)
if (($# < 2)); then
echo "Missing value for $1" >&2
-t | --expire-time)
[[ $val =~ ^-?[0-9]+$ ]] || {
echo "Invalid $opt value (milliseconds expected): $val" >&2
exit 1
fi
urgency="$2"
parsed_option_args=2
return 0
;;
--app-name)
if (($# < 2)); then
echo "Missing value for $1" >&2
exit 1
fi
app_name=$2
parsed_option_args=2
return 0
;;
--image)
if (($# < 2)); then
echo "Missing value for $1" >&2
exit 1
fi
image=$2
parsed_option_args=2
return 0
;;
--exec)
if (($# < 2)); then
echo "Missing value for $1" >&2
exit 1
fi
exec_command=$2
parsed_option_args=2
return 0
}
expire_timeout=$val
;;
esac
return 1
parsed_option_args=$nargs
return 0
}
while (($# > 0)); do
@@ -77,47 +91,121 @@ while (($# > 0)); do
done
if (($# < 1)); then
echo "Usage: omarchy-notification-send [--exec <command>] [--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [--image <path-or-uri>] <headline> [description] [notify-send options]"
usage
exit 1
fi
headline=$1
shift
if (($# > 0)) && [[ $1 != -* ]]; then
# The description is the next positional, taken as text even when it begins with
# a dash — a body like "-50% off" or a negative number is content, not options.
# Only a recognized option flag or --exec in that slot is not the description.
known_flag() {
case $1 in
-g | --glyph | -u | --urgency | --app-name | -i | --icon | -t | --expire-time | --image | -r | --replace-id | -p | --print-id | --exec) return 0 ;;
--glyph=* | --urgency=* | --app-name=* | --icon=* | --expire-time=* | --image=* | --replace-id=*) return 0 ;;
esac
return 1
}
if (($# > 0)) && ! known_flag "$1"; then
description=$1
shift
fi
while (($# > 0)); do
if parse_omarchy_option "$@"; then
if [[ $1 == "--exec" ]]; then
# --exec consumes the rest of the line as the click command's argv. The
# caller's shell already tokenized those words into discrete arguments, and
# the shell runs them as-is (never re-parsed), so untrusted data in an
# argument is only ever one argument and can never become a command.
# Detected only here, after the headline/description positionals are
# captured, so an untrusted headline that is literally "--exec" is taken as
# text and can't be mistaken for the delimiter. --exec therefore comes last.
shift
exec_args=("$@")
exec_present=1
break
elif parse_omarchy_option "$@"; then
shift "$parsed_option_args"
else
args+=("$1")
shift
echo "Unknown option: $1" >&2
usage
exit 1
fi
done
# Tag as a user-action toast so it pops through DND.
args+=("-a" "$app_name" "-u" "$urgency")
case $urgency in
low) urgency_byte=0 ;;
normal) urgency_byte=1 ;;
critical) urgency_byte=2 ;;
*)
echo "Unknown urgency: $urgency (use low, normal, or critical)" >&2
exit 1
;;
esac
# a{sv} hints, as busctl triples (key, variant type, value). urgency is a byte;
# the rest are strings. The click command rides here as omarchy-exec-argv, built
# only from --exec below.
hints=(urgency y "$urgency_byte")
if [[ -n $glyph ]]; then
args+=("--hint=string:omarchy-glyph:$glyph")
hints+=(omarchy-glyph s "$glyph")
fi
if [[ -n $image ]]; then
args+=("--hint=string:image-path:$image")
hints+=(image-path s "$image")
fi
# The shell runs the click command itself, from a copy it keeps alongside the
# on-screen popup. A libnotify action would instead keep this process blocked
# until the click, and die unanswered whenever the shell restarts underneath it.
if [[ -n $exec_command ]]; then
args+=("--hint=string:omarchy-exec:$exec_command")
if ((exec_present)); then
if ((${#exec_args[@]} == 0)) || [[ -z ${exec_args[0]} ]]; then
echo "--exec needs a command: --exec <program> [args...]" >&2
exit 1
fi
# A single word with a space is almost always a whole command passed as one
# quoted string — which would run a program literally named that. Splitting it
# ourselves is exactly the injection we avoid, so reject it and point at the
# unquoted form instead.
if ((${#exec_args[@]} == 1)) && [[ ${exec_args[0]} == *[[:space:]]* ]]; then
echo "--exec takes the command as separate words, not one quoted string." >&2
echo "Write: --exec ${exec_args[0]}" >&2
exit 1
fi
# NUL-delimit into jq so every byte survives as data: jq's own --args would eat
# a bare "--", and a newline in an arg must not split the vector.
exec_argv_json=$(printf '%s\0' "${exec_args[@]}" | jq -Rsc 'split("\u0000")[:-1]')
hints+=(omarchy-exec-argv s "$exec_argv_json")
fi
if [[ -n $description ]]; then
notify-send "${args[@]}" "$headline" "$description"
hint_count=$((${#hints[@]} / 3))
# Call org.freedesktop.Notifications.Notify directly — never notify-send. Its
# argv parsing is the surface that reinterprets a relayed headline like
# `--hint=…` or `-rf` as options or hints; busctl takes each value as one typed
# D-Bus parameter instead, and the leading `--` keeps a dash-leading value
# (headline, description, a negative timeout) positional rather than a busctl
# option. So the summary and body are strings that can never become a hint, and
# omarchy-exec-argv is set only from --exec.
#
# Signature susssasa{sv}i: app_name, replaces_id, app_icon, summary, body,
# actions (empty), hints, expire_timeout. replaces_id (from -r) updates a toast
# in place; -p prints the returned id so a caller can reuse it.
notify_cmd=(
busctl --user -- call
org.freedesktop.Notifications /org/freedesktop/Notifications
org.freedesktop.Notifications Notify susssasa{sv}i
"$app_name" "$replaces_id" "$app_icon" "$headline" "$description"
0
"$hint_count" "${hints[@]}"
"$expire_timeout"
)
if ((print_id)); then
# busctl prints the UINT32 return as "u <id>"; emit just the id.
out=$("${notify_cmd[@]}")
printf '%s\n' "${out##* }"
else
notify-send "${args[@]}" "$headline"
"${notify_cmd[@]}" >/dev/null
fi
+130
View File
@@ -0,0 +1,130 @@
#!/bin/bash
# omarchy:summary=Run OpenClaw's setup wizard the way Omarchy needs it: in the terminal, installing the gateway as a user service, and returning when it is done.
# Not bare `openclaw onboard`: as of 2026.9.1 that is the guided flow, which
# ends by running a foreground gateway and handing off to a browser tab, and
# never returns. --install-daemon keeps it to the classic wizard (minimal
# prompts with --flow quickstart) that installs the gateway service, and
# --skip-ui drops its closing Control UI/TUI prompt since whoever called this
# opens one next.
#
# That classic wizard has one wrinkle: with --skip-ui it prints "Onboarding
# complete" and then never exits. Upstream only calls exit(0) when it launched
# the TUI, and the model sign-in leaves an open socket that keeps the process
# alive otherwise. So this script watches for the gateway to answer and then
# stops the wizard. That is safe because every prompt in the quickstart flow
# runs before the gateway service is installed and reachable: by the time the
# dashboard answers, only the closing notes are left.
set -uo pipefail
wizard=(openclaw onboard --flow quickstart --install-daemon --skip-ui)
config=$HOME/.openclaw/openclaw.json
settle_seconds=${OMARCHY_OPENCLAW_ONBOARD_SETTLE_SECONDS:-3}
# Counted from the moment the config exists, i.e. once the wizard has applied
# setup; the prompts before that take as long as the user takes.
gateway_timeout=${OMARCHY_OPENCLAW_ONBOARD_GATEWAY_TIMEOUT:-180}
gateway_answers() {
timeout 10 openclaw dashboard --json 2>/dev/null | jq -e '.ok == true' >/dev/null 2>&1
}
# A gateway already answering means OpenClaw is set up, and this run must not
# read its own success off state that predates it: the wizard's repair pass
# would be stopped mid-prompt the moment the watcher looked. Nothing to do.
if [[ -f $config ]] && gateway_answers; then
echo "OpenClaw is already set up and its gateway is running." >&2
echo "To change providers or settings, run: openclaw onboard --classic" >&2
exit 0
fi
# Marks when this run began, so a config left behind by an earlier, incomplete
# setup is not mistaken for this run having applied its own: the gateway
# deadline below must not start ticking while the user is still at prompts.
started=$(mktemp)
trap 'rm -f "$started"' EXIT
# Backgrounded so this script can watch it, but with the terminal kept as its
# stdin (bash would otherwise hand a background job /dev/null). Job control is
# off in a script, so it stays in the terminal's foreground process group and
# reads from it freely. Ctrl-C does not reach it directly, though: bash starts
# async children with SIGINT ignored when job control is off, so the INT trap
# below is what turns Ctrl-C into the wizard's exit.
"${wizard[@]}" <&0 &
wizard_pid=$!
stop_wizard() {
kill -TERM "$wizard_pid" 2>/dev/null || true
}
# Any signal at this script, whether Ctrl-C from the terminal or a kill aimed
# at its pid alone, takes the wizard down with it rather than leaving it
# running unwatched.
trap 'stop_wizard' INT TERM HUP
# Whether this run has applied setup: the config exists and is not older than
# the run itself.
config_applied() {
[[ -f $config && ! $started -nt $config ]]
}
# Whether this run's gateway is up: setup applied, and the process answering
# on the gateway's port is the main process of the service the wizard
# installs. Not the dashboard alone: with no config on disk `openclaw
# dashboard --json` still probes the default loopback port, so a gateway left
# behind by something else (an earlier guided onboarding's foreground
# gateway, say) would read as this run's success while the user is still at
# the first prompt. And not the unit being active either: its Type=simple
# counts it active from the fork, before it has found the port taken by such
# an orphan, and the app would then open on the orphan rather than the
# service this run installed.
gateway_ready() {
config_applied || return 1
local json port listener main_pid
json=$(timeout 10 openclaw dashboard --json 2>/dev/null) || return 1
jq -e '.ok == true' <<<"$json" >/dev/null 2>&1 || return 1
port=$(jq -r '.port // empty' <<<"$json" 2>/dev/null)
[[ -n $port ]] || return 1
listener=$(ss -ltnpH "sport = :$port" 2>/dev/null | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | head -1)
main_pid=$(systemctl --user show -p MainPID --value openclaw-gateway.service 2>/dev/null)
[[ -n $listener && -n $main_pid && $main_pid != 0 && $listener == "$main_pid" ]]
}
stopped=false
timed_out=false
config_seen_at=
while kill -0 "$wizard_pid" 2>/dev/null; do
sleep 2
if gateway_ready; then
# Let the outro finish printing, then end the process the wizard leaves
# running.
sleep "$settle_seconds"
stop_wizard
stopped=true
break
fi
config_applied || continue
: "${config_seen_at:=$SECONDS}"
if (( SECONDS - config_seen_at >= gateway_timeout )); then
# Setup was applied but the service never came up (port taken, unit
# failing, ...): the wizard would sit in its never-exiting state forever,
# and so would whoever is waiting on this script.
stop_wizard
timed_out=true
break
fi
done
wait "$wizard_pid"
rc=$?
if [[ $timed_out == true ]]; then
echo "OpenClaw's gateway did not come up within ${gateway_timeout}s of setup finishing." >&2
echo "Check it with: openclaw gateway status" >&2
exit 1
fi
# A wizard stopped here after the gateway came up did its job. One that
# exited on its own, including a user who chose "Skip for now" (no config,
# non-zero), keeps its own exit code.
[[ $stopped == true ]] && rc=0
exit "$rc"
+3 -3
View File
@@ -7,15 +7,15 @@
if omarchy-pkg-missing "$@"; then
if (( EUID == 0 )); then
pacman -S --noconfirm --needed "$@" || exit 1
pacman -S --noconfirm --needed -- "$@" || exit 1
else
sudo pacman -S --noconfirm --needed "$@" || exit 1
sudo pacman -S --noconfirm --needed -- "$@" || exit 1
fi
fi
for pkg in "$@"; do
# Secondary check to handle states where pacman doesn't actually register an error
if ! pacman -Q "$pkg" &>/dev/null; then
if ! pacman -Q -- "$pkg" &>/dev/null; then
echo -e "\033[31mError: Package '$pkg' did not install\033[0m" >&2
exit 1
fi
+2 -2
View File
@@ -4,12 +4,12 @@
# omarchy:args=<packages...>
if omarchy-pkg-missing "$@"; then
yay -S --noconfirm --needed "$@" || exit 1
yay -S --noconfirm --needed -- "$@" || exit 1
fi
for pkg in "$@"; do
# Secondary check to handle states where pacman doesn't actually register an error
if ! pacman -Q "$pkg" &>/dev/null; then
if ! pacman -Q -- "$pkg" &>/dev/null; then
echo -e "\033[31mError: Package '$pkg' did not install\033[0m" >&2
exit 1
fi
+3 -2
View File
@@ -24,6 +24,7 @@ if [[ -n $pkg_names ]]; then
source omarchy-sudo-keepalive
echo "$pkg_names" | sed 's/^/aur\//' | tr '\n' ' ' | xargs yay -S --noconfirm
sudo updatedb
omarchy-show-done
code=$?
sudo updatedb --prune-bind-mounts=no --add-prunepaths=/.snapshots
omarchy-show-done $code
fi
+1 -1
View File
@@ -22,5 +22,5 @@ if [[ -n $pkg_names ]]; then
# Convert newline-separated selections to space-separated for pacman
echo "$pkg_names" | tr '\n' ' ' | xargs sudo pacman -S --noconfirm
omarchy-show-done
omarchy-show-done $?
fi
+1 -1
View File
@@ -4,7 +4,7 @@
# omarchy:args=<packages...>
for pkg in "$@"; do
if ! pacman -Q "$pkg" &>/dev/null; then
if ! pacman -Q -- "$pkg" &>/dev/null; then
exit 0
fi
done
+1 -1
View File
@@ -4,7 +4,7 @@
# omarchy:args=<packages...>
for pkg in "$@"; do
pacman -Q "$pkg" &>/dev/null || exit 1
pacman -Q -- "$pkg" &>/dev/null || exit 1
done
exit 0
+1 -1
View File
@@ -20,5 +20,5 @@ pkg_names=$(yay -Qqe | fzf "${fzf_args[@]}")
if [[ -n $pkg_names ]]; then
# Convert newline-separated selections to space-separated for yay
echo "$pkg_names" | tr '\n' ' ' | xargs sudo pacman -Rns --noconfirm
omarchy-show-done
omarchy-show-done $?
fi
+7
View File
@@ -93,6 +93,13 @@ if [[ -z $url ]]; then
[[ -n $url ]] || fail "a git URL is required"
fi
# Refuse a URL that names a git option or a transport helper before cloning, so
# an untrusted URL cannot run a command before the plugin is validated or
# enabled. The check is shared with omarchy-theme-install and explains itself; a
# missing checker leaves this non-zero, which refuses the URL rather than
# cloning it.
omarchy-git-url-check "$url" || exit 1
if (( ! ASSUME_YES )); then
cat >&2 <<WARN
+4 -2
View File
@@ -3,5 +3,7 @@
# omarchy:summary=Restore the default Omarchy Plymouth boot theme and SDDM login screen
# omarchy:requires-sudo=true
omarchy-refresh-plymouth
omarchy-refresh-sddm
set -euo pipefail
"$OMARCHY_PATH/bin/omarchy-refresh-plymouth"
"$OMARCHY_PATH/bin/omarchy-refresh-sddm"
+365 -67
View File
@@ -5,79 +5,377 @@
# omarchy:examples=omarchy plymouth set '#1d2021' '#ebdbb2' ~/.local/state/omarchy/current/theme/plymouth/logo.png
# omarchy:requires-sudo=true
# Configure the Plymouth boot theme with a custom background color, text color, and logo.
# Stages the change in a temp dir, then commits the staged files to /usr/share and
# rebuilds the initramfs. Also syncs the SDDM login screen (the post-logout
# screen) with the same colors and logo so boot/login stay visually unified.
set -euo pipefail
if (( $# != 3 )); then
# Build the authoritative theme in a root-owned directory, then publish each
# fixed destination atomically. The caller opens the selected logo before sudo,
# so the privileged process never resolves a user-controlled input path.
usage() {
echo "Usage: omarchy-plymouth-set <background-hex> <text-hex> <path-to-logo.png>" >&2
exit 1
fi
}
bg_hex="${1#\#}"
text_hex="${2#\#}"
logo_path="$3"
if ! [[ $bg_hex =~ ^[0-9a-fA-F]{6}$ ]]; then
echo "Invalid background color: $1 (expected #RRGGBB)" >&2
exit 1
fi
if ! [[ $text_hex =~ ^[0-9a-fA-F]{6}$ ]]; then
echo "Invalid text color: $2 (expected #RRGGBB)" >&2
exit 1
fi
if [[ ! -f $logo_path ]]; then
echo "Logo file not found: $logo_path" >&2
exit 1
fi
bg_r=$(awk -v n=$((16#${bg_hex:0:2})) 'BEGIN{printf "%.3f", n/255}')
bg_g=$(awk -v n=$((16#${bg_hex:2:2})) 'BEGIN{printf "%.3f", n/255}')
bg_b=$(awk -v n=$((16#${bg_hex:4:2})) 'BEGIN{printf "%.3f", n/255}')
theme_dir="/usr/share/plymouth/themes/omarchy"
staging_dir=$(mktemp -d)
trap 'rm -rf "$staging_dir"' EXIT
find "$OMARCHY_PATH/default/plymouth" -maxdepth 1 -type f -exec cp -t "$staging_dir/" {} +
cp "$logo_path" "$staging_dir/logo.png"
sed -i \
-e "s/^Window.SetBackgroundTopColor.*/Window.SetBackgroundTopColor($bg_r, $bg_g, $bg_b);/" \
-e "s/^Window.SetBackgroundBottomColor.*/Window.SetBackgroundBottomColor($bg_r, $bg_g, $bg_b);/" \
"$staging_dir/omarchy.script"
for asset in bullet.png entry.png lock.png progress_bar.png; do
magick "$staging_dir/$asset" -channel RGB +level-colors "#$text_hex","#$text_hex" "$staging_dir/$asset"
done
sudo cp -a --no-preserve=mode,ownership "$staging_dir/." "$theme_dir/"
sudo plymouth-set-default-theme omarchy
if omarchy-cmd-present limine-mkinitcpio; then
sudo limine-mkinitcpio
if (( $# == 3 )); then
mode=set
elif (( $# == 1 )); then
case "$1" in
--refresh-default)
mode=refresh-plymouth
;;
--refresh-sddm-default)
mode=refresh-sddm
;;
*)
usage
;;
esac
else
sudo mkinitcpio -P
usage
fi
# Sync the SDDM login screen with the same colors and logo.
sddm_dir="/usr/share/sddm/themes/omarchy"
sddm_template="$OMARCHY_PATH/default/sddm/omarchy/Main.qml"
if (( EUID == 0 )); then
echo "Error: run omarchy-plymouth-set as your user, not under sudo." >&2
exit 1
fi
sed \
-e "s/#1a1b26/#$bg_hex/g" \
-e "s/#ffffff/#$text_hex/g" \
"$sddm_template" | sudo tee "$sddm_dir/Main.qml" >/dev/null
logo_fd=
if [[ $mode != "set" ]]; then
bg_hex=
text_hex=
else
bg_hex="${1#\#}"
text_hex="${2#\#}"
logo_path="$3"
sudo cp "$logo_path" "$sddm_dir/logo.png"
for asset in bullet.png entry.png lock.png; do
sudo cp "$staging_dir/$asset" "$sddm_dir/$asset"
done
for asset in entry lock; do
magick "$staging_dir/$asset.png" -channel RGB +level-colors "#f7768e","#f7768e" "$staging_dir/$asset-failed.png"
sudo cp "$staging_dir/$asset-failed.png" "$sddm_dir/$asset-failed.png"
done
sudo rm -f "$sddm_dir/logo.svg"
if ! [[ $bg_hex =~ ^[0-9a-fA-F]{6}$ ]]; then
echo "Invalid background color: $1 (expected #RRGGBB)" >&2
exit 1
fi
if ! [[ $text_hex =~ ^[0-9a-fA-F]{6}$ ]]; then
echo "Invalid text color: $2 (expected #RRGGBB)" >&2
exit 1
fi
if [[ ! -f $logo_path ]]; then
echo "Logo file not found: $logo_path" >&2
exit 1
fi
if [[ -L $logo_path ]]; then
echo "Logo file is a symlink, which is not accepted: $logo_path" >&2
exit 1
fi
# Open the logo while still unprivileged. A replacement symlink to a root-only
# file therefore fails here instead of being followed after sudo starts.
if ! exec {logo_fd}<"$logo_path"; then
echo "Unable to open logo file as the current user: $logo_path" >&2
exit 1
fi
if [[ ! -f /proc/$$/fd/$logo_fd ]]; then
echo "Logo input is no longer a regular file: $logo_path" >&2
exit 1
fi
fi
run_root_transaction() {
sudo /bin/bash -c '
set -eEuo pipefail
PATH=/usr/bin:/bin
export PATH
umask 077
# Every check below is a bare assertion that aborts under set -e. Name the
# subject of each one so a refusal reaches the user instead of exiting mute.
failure_context="the privileged Plymouth transaction"
failure_reported=
report_failure() {
[[ -z $failure_reported ]] || return 0
failure_reported=1
printf "omarchy-plymouth-set: refusing to publish: %s failed validation\n" "$failure_context" >&2
if [[ -n ${failure_hint:-} ]]; then
printf "omarchy-plymouth-set: %s\n" "$failure_hint" >&2
fi
}
trap report_failure ERR
mode=$1
source_root=$2
bg_hex=$3
text_hex=$4
max_asset_size=$5
failure_context="the arguments of the privileged transaction"
[[ $mode == "set" || $mode == "refresh-plymouth" || $mode == "refresh-sddm" ]]
[[ $source_root == /* ]]
[[ $max_asset_size =~ ^[0-9]+$ ]]
(( max_asset_size > 0 ))
failure_context="the Omarchy source tree $source_root"
canonical_source_root=$(realpath -e -- "$source_root")
[[ $canonical_source_root == "$source_root" ]]
validate_trusted_directory() {
local directory=$1 canonical uid directory_mode
failure_context="directory $directory"
canonical=$(realpath -e -- "$directory")
[[ $canonical == "$directory" && -d $directory && ! -L $directory ]]
while :; do
failure_context="directory $directory (must be root-owned and not group- or world-writable)"
uid=$(stat -c %u -- "$directory")
directory_mode=$(stat -c %a -- "$directory")
(( uid == 0 ))
(( (8#$directory_mode & 0022) == 0 ))
[[ $directory == "/" ]] && break
directory=${directory%/*}
[[ -n $directory ]] || directory=/
done
}
validate_trusted_configuration_file() {
local configuration=$1 canonical uid configuration_mode size
failure_context="root configuration $configuration"
[[ -f $configuration && ! -L $configuration ]]
canonical=$(realpath -e -- "$configuration")
[[ $canonical == "$configuration" ]]
validate_trusted_directory "${configuration%/*}"
uid=$(stat -c %u -- "$configuration")
configuration_mode=$(stat -c %a -- "$configuration")
size=$(stat -c %s -- "$configuration")
(( uid == 0 ))
(( (8#$configuration_mode & 0022) == 0 ))
(( size > 0 && size <= 4096 ))
}
# A packaged tree must be root-owned. A development checkout is the one
# deliberate exception: omarchy dev link records its canonical path in a
# root-owned /etc/omarchy.conf. That is already an explicit decision to run
# privileged Omarchy commands from user-editable code in the checkout, so
# reading its packaged assets does not widen the development trust boundary.
development_source=false
source_root_uid=$(stat -c %u -- "$source_root")
if (( source_root_uid != 0 )); then
omarchy_conf=/etc/omarchy.conf
failure_context="$source_root is user-owned and $omarchy_conf must contain its trusted dev-link authorization; run omarchy dev link to authorize it"
failure_hint="$source_root is user-owned; run omarchy dev link to authorize this development checkout, or omarchy dev unlink to use the packaged tree"
validate_trusted_configuration_file "$omarchy_conf"
# validate_trusted_configuration_file walks /etc up to / and leaves its own
# subject behind in failure_context. Without restoring ours, a checkout
# that simply is not the authorized one refuses with "directory / must be
# root-owned and not group- or world-writable" -- naming a directory that
# passed, and sending the reader after a filesystem problem that is not
# there.
failure_context="the dev-link authorization in $omarchy_conf, which must name $source_root"
quoted_source_root=$source_root
quoted_source_root=${quoted_source_root//\\/\\\\}
quoted_source_root=${quoted_source_root//\"/\\\"}
quoted_source_root=${quoted_source_root//\$/\\\$}
quoted_source_root=${quoted_source_root//\`/\\\`}
expected_config_line="export OMARCHY_PATH=\"$quoted_source_root\""
mapfile -t omarchy_config_lines <"$omarchy_conf"
(( ${#omarchy_config_lines[@]} == 1 ))
[[ ${omarchy_config_lines[0]} == "$expected_config_line" ]]
development_source=true
failure_hint=
fi
if [[ $mode == "set" ]]; then
[[ $bg_hex =~ ^[0-9a-fA-F]{6}$ ]]
[[ $text_hex =~ ^[0-9a-fA-F]{6}$ ]]
fi
theme_dir=/usr/share/plymouth/themes/omarchy
sddm_dir=/usr/share/sddm/themes/omarchy
plymouth_theme_assets=(
bullet.png
entry.png
lock.png
logo.png
omarchy.plymouth
omarchy.script
preview-unlock.png
progress_bar.png
progress_box.png
)
plymouth_default_assets=("${plymouth_theme_assets[@]}" logos/oma.png)
sddm_theme_assets=(Main.qml bullet.png entry-failed.png entry.png lock-failed.png lock.png logo.png)
sddm_default_assets=("${sddm_theme_assets[@]}" metadata.desktop theme.conf)
plymouth_assets=()
sddm_assets=()
case "$mode" in
set)
plymouth_assets=("${plymouth_theme_assets[@]}")
sddm_assets=("${sddm_theme_assets[@]}")
;;
refresh-plymouth)
plymouth_assets=("${plymouth_default_assets[@]}")
;;
refresh-sddm)
sddm_assets=("${sddm_default_assets[@]}")
;;
esac
validate_trusted_file() {
local source=$1 canonical uid file_mode size
failure_context="packaged source file $source"
[[ -f $source && ! -L $source ]]
canonical=$(realpath -e -- "$source")
[[ $canonical == "$source" ]]
file_mode=$(stat -c %a -- "$source")
size=$(stat -c %s -- "$source")
(( size > 0 && size <= max_asset_size ))
if ! $development_source; then
validate_trusted_directory "${source%/*}"
uid=$(stat -c %u -- "$source")
(( uid == 0 ))
(( (8#$file_mode & 0022) == 0 ))
fi
}
copy_trusted_file() {
local source=$1 destination=$2
validate_trusted_file "$source"
install -o 0 -g 0 -m 0600 -- "$source" "$destination"
}
staging_dir=$(mktemp -d /tmp/omarchy-plymouth.XXXXXXXX)
temporary=
cleanup() {
[[ -z $temporary ]] || rm -f -- "$temporary"
rm -rf -- "$staging_dir"
}
trap cleanup EXIT HUP INT TERM
chown 0:0 -- "$staging_dir"
chmod 0700 -- "$staging_dir"
plymouth_stage=$staging_dir/plymouth
sddm_stage=$staging_dir/sddm
mkdir -m 0700 -p -- "$plymouth_stage/logos" "$sddm_stage"
for asset in "${plymouth_assets[@]}"; do
copy_trusted_file "$source_root/default/plymouth/$asset" "$plymouth_stage/$asset"
done
if [[ $mode == "set" ]]; then
# stdin was opened by the unprivileged caller. Read no more than the
# documented limit into the root-owned stage before doing other work.
failure_context="the selected logo (expected 1 to $max_asset_size bytes)"
head -c "$((max_asset_size + 1))" >"$plymouth_stage/logo.png"
logo_size=$(stat -c %s -- "$plymouth_stage/logo.png")
(( logo_size > 0 && logo_size <= max_asset_size ))
chown 0:0 -- "$plymouth_stage/logo.png"
chmod 0600 -- "$plymouth_stage/logo.png"
cp --reflink=never -- "$plymouth_stage/logo.png" "$sddm_stage/logo.png"
bg_r=$(awk -v n=$((16#${bg_hex:0:2})) "BEGIN{printf \"%.3f\", n/255}")
bg_g=$(awk -v n=$((16#${bg_hex:2:2})) "BEGIN{printf \"%.3f\", n/255}")
bg_b=$(awk -v n=$((16#${bg_hex:4:2})) "BEGIN{printf \"%.3f\", n/255}")
sed -i \
-e "s/^Window.SetBackgroundTopColor.*/Window.SetBackgroundTopColor($bg_r, $bg_g, $bg_b);/" \
-e "s/^Window.SetBackgroundBottomColor.*/Window.SetBackgroundBottomColor($bg_r, $bg_g, $bg_b);/" \
"$plymouth_stage/omarchy.script"
for asset in bullet.png entry.png lock.png progress_bar.png; do
magick "$plymouth_stage/$asset" -channel RGB +level-colors "#$text_hex","#$text_hex" "$plymouth_stage/$asset"
done
copy_trusted_file "$source_root/default/sddm/omarchy/Main.qml" "$sddm_stage/Main.qml"
sed -i \
-e "s/#1a1b26/#__OMARCHY_SDDM_BG__/g" \
-e "s/#ffffff/#__OMARCHY_SDDM_TEXT__/g" \
-e "s/#__OMARCHY_SDDM_BG__/#$bg_hex/g" \
-e "s/#__OMARCHY_SDDM_TEXT__/#$text_hex/g" \
"$sddm_stage/Main.qml"
for asset in bullet.png entry.png lock.png; do
cp --reflink=never -- "$plymouth_stage/$asset" "$sddm_stage/$asset"
done
for asset in entry lock; do
magick "$plymouth_stage/$asset.png" -channel RGB +level-colors "#f7768e","#f7768e" "$sddm_stage/$asset-failed.png"
done
chown -R 0:0 -- "$staging_dir"
find "$staging_dir" -type f -exec chmod 0600 -- {} +
elif (( ${#sddm_assets[@]} )); then
for asset in "${sddm_assets[@]}"; do
copy_trusted_file "$source_root/default/sddm/omarchy/$asset" "$sddm_stage/$asset"
done
fi
if (( ${#plymouth_assets[@]} )); then
validate_trusted_directory "$theme_dir"
if [[ $mode == "refresh-plymouth" ]]; then
validate_trusted_directory "$theme_dir/logos"
fi
fi
if (( ${#sddm_assets[@]} )); then
validate_trusted_directory "$sddm_dir"
fi
publish_asset() {
local source=$1 destination=$2 parent filename source_size copied_size
failure_context="destination $destination"
[[ -f $source && ! -L $source ]]
(( $(stat -c %u -- "$source") == 0 ))
source_size=$(stat -c %s -- "$source")
(( source_size > 0 && source_size <= max_asset_size ))
[[ $destination == /* && $destination != */ && $destination != *"/../"* ]]
parent=${destination%/*}
filename=${destination##*/}
[[ -n $parent && -n $filename && $filename != "." && $filename != ".." ]]
validate_trusted_directory "$parent"
temporary=$(mktemp --tmpdir="$parent" ".$filename.omarchy-new.XXXXXXXX")
install -o 0 -g 0 -m 0644 -- "$source" "$temporary"
copied_size=$(stat -c %s -- "$temporary")
(( copied_size == source_size ))
cmp -s -- "$source" "$temporary"
sync -f -- "$temporary"
mv --no-copy -fT -- "$temporary" "$destination"
temporary=
}
if (( ${#plymouth_assets[@]} )); then
for asset in "${plymouth_assets[@]}"; do
publish_asset "$plymouth_stage/$asset" "$theme_dir/$asset"
done
fi
if (( ${#sddm_assets[@]} )); then
for asset in "${sddm_assets[@]}"; do
publish_asset "$sddm_stage/$asset" "$sddm_dir/$asset"
done
validate_trusted_directory "$sddm_dir"
rm -f -- "$sddm_dir/logo.svg"
fi
' bash "$mode" "$OMARCHY_PATH" "$bg_hex" "$text_hex" "$((64 * 1024 * 1024))"
}
if [[ $mode == "set" ]]; then
run_root_transaction <&"$logo_fd"
else
run_root_transaction </dev/null
fi
if [[ $mode != "refresh-sddm" ]]; then
sudo plymouth-set-default-theme omarchy
if omarchy-cmd-present limine-mkinitcpio; then
sudo limine-mkinitcpio
else
sudo mkinitcpio -P
fi
fi
+20 -4
View File
@@ -141,7 +141,8 @@ say() {
notice() {
clear_logo
echo
gum spin --spinner "pulse" --title "$1" -- sleep "${2:-2}"
printf '%*s\033[32m● \033[0m%s\n' "$PADDING_LEFT" '' "$1"
sleep "${2:-2}"
echo
}
@@ -448,7 +449,7 @@ greeter_screen() {
rows=$(stty size 2>/dev/null </dev/tty | awk '{print $1}')
[[ $rows =~ ^[0-9]+$ ]] || rows=${LINES:-24}
tagline="Beautiful, Modern & Opinionated Linux by DHH"
tagline="Beautiful, Fun & Agentic Linux by DHH"
hint="Press Return to Start Setup"
printf '%s%s' "$HIDE_CURSOR" "$CLEAR"
@@ -676,6 +677,15 @@ user_groups() {
if [[ -f $PROVISIONING_DIR/groups ]]; then
while IFS= read -r group; do
[[ -n $group ]] || continue
# Never replay old privileged group defaults. Docker is always opt-in.
# Input is only retained when the factory image has one of the features
# whose installer deliberately grants access to raw input devices.
[[ $group == "docker" ]] && continue
if [[ $group == "input" ]] &&
! pacman -Qq xpadneo-dkms &>/dev/null &&
! pacman -Qq ydotool &>/dev/null; then
continue
fi
getent group "$group" >/dev/null || continue
[[ ",$groups," == *",$group,"* ]] || groups+=",$group"
done <"$PROVISIONING_DIR/groups"
@@ -736,6 +746,12 @@ create_user() {
# for specific commands), and a duplicate grant is harmless.
echo "%wheel ALL=(ALL:ALL) ALL" >/etc/sudoers.d/00-omarchy-wheel
chmod 440 /etc/sudoers.d/00-omarchy-wheel
source "$OMARCHY_PATH/install/helpers/browser-policy.sh"
for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do
[[ -d $dir || -L $dir ]] || continue
browser_policy_setup_dir "$dir"
done
}
install_authorized_keys() {
@@ -776,7 +792,7 @@ configure_login() {
# After=) is what makes it deterministic — no sleep/race against SDDM's startup.
install_autologin_once_cleanup() {
local unit=omarchy-provision-autologin-once.service
cat >"/etc/systemd/system/$unit" <<UNIT
sed "s|@UNIT@|$unit|g" >"/etc/systemd/system/$unit" <<'UNIT'
[Unit]
Description=Drop the first-boot autologin before the next login
Before=display-manager.service
@@ -785,7 +801,7 @@ ConditionPathExists=/etc/sddm.conf.d/autologin.conf
[Service]
Type=oneshot
ExecStart=/usr/bin/rm -f /etc/sddm.conf.d/autologin.conf
ExecStartPost=/usr/bin/rm -f /etc/systemd/system/graphical.target.wants/$unit /etc/systemd/system/$unit
ExecStartPost=/usr/bin/rm -f /etc/systemd/system/graphical.target.wants/@UNIT@ /etc/systemd/system/@UNIT@
[Install]
WantedBy=graphical.target
+9 -1
View File
@@ -84,7 +84,7 @@ fi
# Dev-aware skill symlinks. Cannot live in /etc/skel because OMARCHY_PATH may
# point at a dev checkout (omarchy dev link) where the target differs.
# Loops every skill directory, so shipping a new one needs no edit here.
mkdir -p ~/.agents/skills ~/.claude/skills ~/.codex/skills ~/.pi/agent/skills ~/.gemini/config/skills
mkdir -p ~/.agents/skills ~/.claude/skills ~/.codex/skills ~/.pi/agent/skills ~/.gemini/config/skills ~/.hermes/skills
for skill in "$OMARCHY_PATH"/default/agents/skills/*/; do
skill=${skill%/}
name=${skill##*/}
@@ -93,6 +93,14 @@ for skill in "$OMARCHY_PATH"/default/agents/skills/*/; do
ln -sfn "$skill" ~/.codex/skills/"$name"
ln -sfn "$skill" ~/.pi/agent/skills/"$name"
ln -sfn "$skill" ~/.gemini/config/skills/"$name"
ln -sfn "$skill" ~/.hermes/skills/"$name"
if [[ -d ~/.hermes/profiles ]]; then
for profile in ~/.hermes/profiles/*/; do
[[ -d $profile ]] || continue
mkdir -p "$profile/skills"
ln -sfn "$skill" "$profile/skills/$name"
done
fi
done
mkdir -p ~/Downloads ~/Pictures ~/Videos ~/.config/gtk-3.0
+1 -1
View File
@@ -23,4 +23,4 @@ sudo cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-$channel" /etc/pacman.d/mirr
omarchy-hook pre-refresh-pacman
# Reset all package DBs and then update
sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syyuu --noconfirm
omarchy-update-pacman -Syyuu --noconfirm
+3 -8
View File
@@ -3,11 +3,6 @@
# omarchy:summary=Overwrite the user config for the Plymouth drive decryption and boot sequence with the Omarchy default and rebuild it.
# omarchy:requires-sudo=true
sudo cp -r "$OMARCHY_PATH/default/plymouth/." /usr/share/plymouth/themes/omarchy/
sudo plymouth-set-default-theme omarchy
if omarchy-cmd-present limine-mkinitcpio; then
sudo limine-mkinitcpio
else
sudo mkinitcpio -P
fi
# Reuse the fixed-file publisher so root never resolves the source checkout or
# follows a destination symlink while restoring the packaged assets.
exec "$OMARCHY_PATH/bin/omarchy-plymouth-set" --refresh-default
+3 -2
View File
@@ -3,5 +3,6 @@
# omarchy:summary=Refresh the SDDM theme from default
# omarchy:requires-sudo=true
sudo rm -rf /usr/share/sddm/themes/omarchy
sudo cp -r "$OMARCHY_PATH/default/sddm/omarchy" /usr/share/sddm/themes/omarchy
# Reuse the fixed-file publisher so root never resolves an untrusted source or
# follows a destination symlink while restoring the packaged SDDM theme.
exec "$OMARCHY_PATH/bin/omarchy-plymouth-set" --refresh-sddm-default
+2 -2
View File
@@ -11,8 +11,8 @@ set -e
omarchy-refresh-pacman
# Downgrade any packages to the stable setup
sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Suu --noconfirm
omarchy-update-pacman -Suu --noconfirm
# Ensure all packages are installed
mapfile -t packages < <(grep -v '^#' "$OMARCHY_PATH/install/omarchy-base.packages" | grep -v '^$')
sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm --needed "${packages[@]}"
omarchy-update-pacman -Syu --noconfirm --needed "${packages[@]}"
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# omarchy:summary=Remove the Claude desktop app along with its configuration and caches.
# omarchy:requires-sudo=true
# -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /.
set -euo pipefail
# The app rewrites ~/.config/Claude for as long as it runs, so a removal that
# leaves it running deletes the directory and watches it come straight back.
pkill -x claude-desktop || true
omarchy-pkg-drop claude-desktop
# Not ~/.claude, ~/.claude.json, or ~/.cache/claude-cli-nodejs: those belong
# to the Claude Code CLI, which ships in a different package that survives
# this one. The desktop app keeps its state in its own Electron directories.
rm -rf \
"$HOME/.config/Claude" \
"$HOME/.cache/Claude"
echo ""
echo "Claude has been removed."
+147
View File
@@ -0,0 +1,147 @@
#!/bin/bash
# omarchy:summary=Remove the Hermes desktop app along with the Hermes runtime it installed.
# omarchy:requires-sudo=true
# -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /.
set -euo pipefail
ensure_hermes_stopped() {
python3 - "$HOME" <<'PY'
import os
from pathlib import Path
import sys
home = Path(sys.argv[1])
roots = [str((home / relative).resolve()) for relative in ('.hermes', '.config/Hermes')]
roots.append('/opt/hermes-desktop')
def belongs_to_hermes(target):
target = target.removesuffix(' (deleted)')
return any(target == root or target.startswith(root + '/') for root in roots)
holders = []
for process in Path('/proc').iterdir():
if not process.name.isdigit() or int(process.name) == os.getpid():
continue
try:
if process.stat().st_uid != os.getuid():
continue
targets = [os.fsdecode(arg) for arg in (process / 'cmdline').read_bytes().split(b'\0')]
entries = [process / 'exe', process / 'cwd']
try:
entries.extend((process / 'fd').iterdir())
except PermissionError:
pass
for entry in entries:
try:
targets.append(os.readlink(entry))
except OSError:
pass
if any(belongs_to_hermes(target) for target in targets):
holders.append(process.name)
except (FileNotFoundError, ProcessLookupError, PermissionError):
continue
if holders:
print('Close Hermes and processes using its files before removing it (PIDs: '
+ ', '.join(holders) + '). Then try again.', file=sys.stderr)
sys.exit(1)
PY
}
# Removing an open SQLite WAL leaves a live writer on a deleted generation.
ensure_hermes_stopped
omarchy-pkg-drop hermes-desktop
# The installer leaves a unit waiting to hand the app the Omarchy theme.
systemctl --user stop omarchy-hermes-theme.service 2>/dev/null || true
# The mise CLI is the app's predecessor, not the app itself: Hermes Desktop takes
# it over on install and runs its own runtime instead, so a copy still here is one
# the app never superseded -- an interrupted install, or the terminal CLI from
# before the app existed. Remove Hermes clears that too, scoped by the installer
# to what Omarchy owns so a hermes the user set up themselves is left alone.
# Tolerated here rather than fatal, so the ~/.hermes handling below still runs;
# the failure is answered for at the end instead of being swallowed.
cli_removed=true
ensure_hermes_stopped
omarchy-install-hermes-cli --remove || cli_removed=false
# The app writes this when the runtime it provisions under ~/.hermes has landed,
# and it is the only thing that tells that runtime apart from one the user
# installed themselves -- the paths are the same either way. Without it the app
# never got that far: a machine where it was installed but never launched still
# has whatever was there before, and none of it is ours to delete unasked.
if [[ -f $HOME/.hermes/hermes-agent/.hermes-bootstrap-complete ]]; then
# The checkout and venv, its own uv, its own node. None of it is any use once
# the app is gone, so it goes without asking; what the user made with the app
# is a different question, answered below.
rm -rf \
"$HOME/.hermes/hermes-agent" \
"$HOME/.hermes/bootstrap-cache" \
"$HOME/.hermes/bin" \
"$HOME/.hermes/node"
# Only the wrappers pointing into ~/.hermes, matched as a plain string: the
# path carries a dot, so an unanchored pattern would also claim a wrapper
# pointing at a sibling like ~/xhermes.
for command in hermes hermes-agent hermes-acp; do
wrapper="$HOME/.local/bin/$command"
if [[ -f $wrapper && ! -L $wrapper ]] && grep -qF "$HOME/.hermes" "$wrapper"; then
rm -f "$wrapper"
fi
done
# When Hermes brought its own Node it symlinked these next to its own commands,
# and they point at what we just deleted. Only the links into ~/.hermes: a
# system Node, or someone else's, lives somewhere else entirely.
for command in node npm npx; do
link="$HOME/.local/bin/$command"
if [[ -L $link && $(readlink "$link") == "$HOME/.hermes"/* ]]; then
rm -f "$link"
fi
done
fi
# What survives to here is the user's: the chats, memories and skills in
# ~/.hermes, the connections and their encrypted tokens in ~/.config/Hermes.
# Keeping them stays the default -- they are small, and finding them intact
# after a reinstall is the better surprise -- but a removal meant to be
# complete should not leave credentials behind either, so the choice is put in
# front of the user with the size, default no. Asked whenever the directories
# exist, marker or no marker: on a machine where the marker never appeared the
# data came from the terminal CLI or an install the app never finished, and it
# is still what removal is asked to clean up. Naming the paths keeps the
# question honest there too -- ~/.hermes may still carry a runtime the app
# never owned, a yes takes that with it, and saying so is the prompt's job.
# Without a terminal to ask in, keeping everything is the answer.
data_removed=false
if [[ -d $HOME/.hermes || -d $HOME/.config/Hermes ]] && [[ -t 0 ]] && omarchy-cmd-present gum; then
# du answers non-zero when either directory is missing, and pipefail would
# turn that into an aborted removal; the size is worth no such thing.
size=$(du -shc "$HOME/.hermes" "$HOME/.config/Hermes" 2>/dev/null | tail -1 | cut -f1 || true)
if gum confirm --default=false "Also delete ~/.hermes and ~/.config/Hermes ($size: chats, memories, skills, connections and tokens)?"; then
ensure_hermes_stopped
rm -rf "$HOME/.hermes" "$HOME/.config/Hermes"
data_removed=true
fi
fi
echo ""
echo "Hermes Desktop has been removed."
if [[ $data_removed == true ]]; then
echo "Its chats, memories, and settings in ~/.hermes and ~/.config/Hermes are gone too."
elif [[ -d $HOME/.hermes || -d $HOME/.config/Hermes ]]; then
echo "Your chats, memories, and skills are still in ~/.hermes,"
echo "and your connections and settings in ~/.config/Hermes."
fi
# The messages above still hold -- the app and its runtime are gone -- but a CLI
# teardown that failed already said so on stderr, and that stands.
if [[ $cli_removed == "false" ]]; then
exit 1
fi
+78
View File
@@ -0,0 +1,78 @@
#!/bin/bash
# omarchy:summary=Remove the OpenClaw agent platform along with its gateway service and web app.
# omarchy:requires-sudo=true
# -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /.
set -euo pipefail
unit_dir="$HOME/.config/systemd/user"
# Only systemd's own word counts as "stopped": a non-zero exit from is-active
# also covers an unreachable user manager, which says nothing about whether
# the process is alive.
unit_stopped() {
local state
state=$(systemctl --user is-active "$1" 2>/dev/null) || true
[[ $state == inactive || $state == failed ]]
}
# The user services OpenClaw installs for itself: the gateway through
# onboarding, and the node host if the user ever paired this machine to
# another gateway. OpenClaw writes them to $HOME/.config regardless of
# XDG_CONFIG_HOME, so look exactly there; no unit means nothing registered.
# Upstream's own teardown knows every piece its install wrote (unit file, the
# default.target.wants enablement symlink, the reload), so prefer it while the
# binary is still installed and fall back to doing the same by hand. A service
# that will not stop aborts the removal: dropping the package would strand the
# live process on deleted code with no way to restart it cleanly.
for unit_file in "$unit_dir"/openclaw-gateway.service "$unit_dir"/openclaw-node.service; do
[[ -f $unit_file ]] || continue
unit=${unit_file##*/}
role=${unit#openclaw-}
role=${role%.service}
if openclaw "$role" uninstall >/dev/null 2>&1 ||
systemctl --user disable --now "$unit" 2>/dev/null ||
unit_stopped "$unit"; then
# .bak is what `gateway install --force` leaves behind when it rewrites a
# unit, so it goes with the unit.
rm -f "$unit_file" "$unit_file.bak" "$unit_dir/default.target.wants/$unit"
systemctl --user daemon-reload 2>/dev/null || true
# A unit that had been failing stays listed as "not-found failed" after
# its file is gone until its failed state is reset.
systemctl --user reset-failed "$unit" 2>/dev/null || true
else
echo "Could not stop $unit; OpenClaw was not removed." >&2
exit 1
fi
done
omarchy-pkg-drop openclaw
# The web app launcher and icon omarchy-install-ai-openclaw created.
rm -f "$HOME/.local/share/applications/OpenClaw.desktop"
rm -f "$HOME/.local/share/icons/hicolor/256x256/apps/openclaw.png"
gtk-update-icon-cache "$HOME/.local/share/icons/hicolor" &>/dev/null || true
# ~/.openclaw stays unless asked: the chats, memories, and credentials in
# there are the user's agent, and finding them intact after a reinstall is the
# better surprise. But it also holds the plugin runtimes and caches OpenClaw
# downloads for itself, easily hundreds of megabytes, so the choice is put in
# front of the user with the size rather than left silent. Without a terminal
# to ask in, keeping it is the answer.
state_removed=false
if [[ -d $HOME/.openclaw && -t 0 ]] && omarchy-cmd-present gum; then
size=$(du -sh "$HOME/.openclaw" 2>/dev/null | cut -f1)
if gum confirm --default=false "Also delete ~/.openclaw ($size: chats, memories, credentials, and downloaded plugins)?"; then
rm -rf "$HOME/.openclaw"
state_removed=true
fi
fi
echo ""
echo "OpenClaw has been removed."
if [[ $state_removed == true ]]; then
echo "Its chats, memories, and settings in ~/.openclaw are gone too."
elif [[ -d $HOME/.openclaw ]]; then
echo "Your agent's chats, memories, and settings are still in ~/.openclaw."
fi
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
# omarchy:summary=Remove the Perplexity desktop app along with its runtime caches.
# omarchy:requires-sudo=true
# -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /.
set -euo pipefail
omarchy-pkg-drop perplexity
# The runtime the app downloads for itself: llama.cpp builds and local models
# under ~/.local/share, the older download location under ~/.cache. Of the
# perplexity-* dirs these are the only ones the desktop app owns; the rest
# belong to Perplexity products that outlive it.
rm -rf \
"$HOME/.cache/Perplexity" \
"$HOME/.cache/perplexity-rpc-server" \
"$HOME/.local/share/perplexity-rpc-server"
# What is left is the user's: the logins and session in ~/.config/Perplexity,
# the secret vault and device identity in ~/.local/state/perplexity, and the
# launcher flags. Keeping them stays the default -- they are small, and being
# signed in after a reinstall is the better surprise -- but a removal meant to
# be complete should not leave credentials behind either, so the choice is put
# in front of the user, default no. Without a terminal to ask in, keeping it
# is the answer -- and gum draws the prompt on stderr, so a redirected stderr
# would block on a question nobody can see.
data_removed=false
if [[ -t 0 && -t 2 ]]; then
# du answers non-zero when a directory is missing, and pipefail would turn
# that into an aborted removal; the size is worth no such thing.
size=$(du -shc "$HOME/.config/Perplexity" "$HOME/.local/state/perplexity" 2>/dev/null | tail -1 | cut -f1 || true)
if gum confirm --default=false "Also delete your Perplexity data ($size: logins, settings, secret vault and device identity)?"; then
rm -rf \
"$HOME/.config/Perplexity" \
"$HOME/.local/state/perplexity"
rm -f "$HOME/.config/perplexity-flags.conf"
data_removed=true
fi
fi
echo ""
echo "Perplexity has been removed."
if [[ $data_removed == "true" ]]; then
echo "Its logins, settings, secret vault, and device identity are gone too."
else
echo "Nothing of yours was touched: not the logins in ~/.config/Perplexity,"
echo "nor the secret vault and device identity in ~/.local/state/perplexity."
fi
+43 -2
View File
@@ -9,7 +9,41 @@ if [[ -z $1 ]]; then
exit 1
fi
cleanup_ocaml_sudo_credentials() {
local status=$?
trap - EXIT HUP INT TERM
if ! /usr/bin/sudo -k >/dev/null 2>&1; then
echo "Failed to invalidate sudo credentials after OCaml removal." >&2
(( status != 0 )) || status=1
fi
exit "$status"
}
begin_ocaml_sudo_boundary() {
trap cleanup_ocaml_sudo_credentials EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
/usr/bin/sudo -k >/dev/null || {
echo "Unable to start OCaml removal with a cold sudo credential state." >&2
exit 1
}
if ! /usr/bin/sudo -N -V >/dev/null 2>&1; then
echo "This sudo does not support --no-update; refusing to remove OCaml." >&2
exit 1
fi
}
remove_php() {
mise uninstall php --all
mise rm -g php
mise tool-alias unset php
# PHP from before it moved to mise
omarchy-pkg-drop php composer php-sqlite xdebug
}
@@ -46,7 +80,9 @@ php)
;;
laravel)
echo -e "Removing Laravel...\n"
composer global remove laravel/installer 2>/dev/null || true
mise x php -- composer global remove laravel/installer 2>/dev/null || true
rm -f "$HOME/.local/bin/laravel" "$HOME/.config/composer/vendor/bin/laravel"
;;
symfony)
echo -e "Removing Symfony CLI...\n"
@@ -87,10 +123,15 @@ dotnet)
mise rm -g dotnet
;;
ocaml)
begin_ocaml_sudo_boundary
echo -e "Removing OCaml...\n"
opam switch remove default -y 2>/dev/null || true
rm -rf ~/.opam 2>/dev/null || true
sudo rm -f /usr/local/bin/opam 2>/dev/null || true
if ! /usr/bin/sudo -N -- /usr/bin/rm -f /usr/local/bin/opam; then
echo "Failed to remove the system opam launcher." >&2
exit 1
fi
;;
clojure)
echo -e "Removing Clojure...\n"
+2
View File
@@ -25,6 +25,7 @@ omarchy-pkg-drop \
libretro-vice-x128-git libretro-vice-x64-git libretro-vice-x64dtv-git libretro-vice-x64sc-git \
libretro-vice-xcbm2-git libretro-vice-xcbm5x0-git libretro-vice-xpet-git \
libretro-vice-xplus4-git libretro-vice-xscpu64-git libretro-vice-xvic-git \
libretro-database-git \
retroarch-joypad-autoconfig-git
rm -rf \
@@ -35,3 +36,4 @@ rm -rf \
echo ""
echo "RetroArch and its cores have been removed."
echo "ROMs and BIOS files at ~/Games/roms and ~/Games/bios were left in place."
+22 -1
View File
@@ -15,7 +15,28 @@ if gum confirm "Are you sure you want to remove all preinstalled web apps, TUI w
# Remove mise stubs
rm -f ~/.local/bin/codex ~/.local/bin/claude ~/.local/bin/agy ~/.local/bin/copilot \
~/.local/bin/gh ~/.local/bin/opencode ~/.local/bin/playwright ~/.local/bin/playwright-cli ~/.local/bin/pi \
~/.local/bin/omp ~/.local/bin/grok ~/.local/bin/crush ~/.local/bin/ghui ~/.local/bin/hunk
~/.local/bin/omp ~/.local/bin/ori ~/.local/bin/grok ~/.local/bin/crush ~/.local/bin/ghui ~/.local/bin/hunk
# Cursor's own installer links ~/.local/bin/cursor-agent as well, so only
# the mise wrapper omarchy-mise-install wrote is a preinstall.
if [[ -f ~/.local/bin/cursor-agent && ! -L ~/.local/bin/cursor-agent ]] &&
grep -Eq '^mise use -g .*"cursor-agent"' ~/.local/bin/cursor-agent; then
rm -f ~/.local/bin/cursor-agent
fi
# Preserve a user-managed Muse launcher at the same path.
if [[ -f ~/.local/bin/muse && ! -L ~/.local/bin/muse ]] &&
grep -Eq '^mise use -g .*"http:muse\[' ~/.local/bin/muse; then
rm -f ~/.local/bin/muse
fi
# Only the wrapper omarchy-install-hermes-cli wrote is a preinstall. Hermes
# Desktop's command, an official install, or anything else at that path is
# the user's, so it is the installer that decides whether the wrapper is its
# own, rather than a copy of its marker kept here.
if omarchy-install-hermes-cli --owns; then
rm -f ~/.local/bin/hermes
fi
omarchy-pkg-drop \
aether \
Loaded 100 of 582 files, more files were not shown because too many files have changed in this diff. Show more