Commit Graph
6170 Commits
Author SHA1 Message Date
ZacharyZhang-NYandClaude Fable 5 8dab891155 Add dev-mirror manager for npm, pip, cargo, go, gem, docker
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKxGW1raAWaqeU8WdHsMsp
2026-08-24 18:42:29 -04:00
ZacharyZhang-NYandClaude Fable 5 aabe95b887 Mirror probe: guard failures under set -e, reject HTTP errors
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKxGW1raAWaqeU8WdHsMsp
2026-08-24 18:40:27 -04:00
ZacharyZhang-NYandClaude Fable 5 434f848dd8 Mirror manager: shellcheck fixes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKxGW1raAWaqeU8WdHsMsp
2026-08-24 18:35:11 -04:00
ZacharyZhang-NYandClaude Fable 5 2bcf0a20f0 Add Arch mirror manager: benchmark, apply, pin, restore, status
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKxGW1raAWaqeU8WdHsMsp
2026-08-24 18:34:55 -04:00
ZacharyZhang-NYandClaude Fable 5 18041c7537 cn commands: use OMARCHY_PATH per runtime convention
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKxGW1raAWaqeU8WdHsMsp
2026-08-24 18:32:51 -04:00
ZacharyZhang-NYandClaude Fable 5 d4002897e3 Add omarchycn CLI entry and cn command group
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKxGW1raAWaqeU8WdHsMsp
2026-08-24 18:29:01 -04:00
ZacharyZhang-NYandClaude Fable 5 4f06b2364f Sync workflow: always open PR, report trial-merge status
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKxGW1raAWaqeU8WdHsMsp
2026-08-24 18:24:38 -04:00
ZacharyZhang-NYandClaude Fable 5 2fd24589c2 Add automated upstream sync workflow
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKxGW1raAWaqeU8WdHsMsp
2026-08-24 18:22:44 -04:00
ZacharyZhang-NYandClaude Fable 5 9ce9f300ae Add OmarchyCN release signing public key and verification docs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKxGW1raAWaqeU8WdHsMsp
2026-08-24 18:18:38 -04:00
ZacharyZhang-NYandClaude Fable 5 db5c7db39a Fix CONTRIBUTING sync wording to match current state
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKxGW1raAWaqeU8WdHsMsp
2026-08-24 18:17:40 -04:00
ZacharyZhang-NYandClaude Fable 5 c1b9d655bb Add NOTICE and governance docs for OmarchyCN
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKxGW1raAWaqeU8WdHsMsp
2026-08-24 18:15:43 -04:00
ZacharyZhang-NYandClaude Fable 5 743ae05acc Feature-focused Chinese README; move PRD and task list to local-only
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKxGW1raAWaqeU8WdHsMsp
2026-08-24 18:06:40 -04:00
ZacharyZhang-NYandClaude Fable 5 8125f24531 Rewrite README in Chinese with OmarchyCN vs Omarchy comparison
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKxGW1raAWaqeU8WdHsMsp
2026-08-24 15:58:00 -04:00
ZacharyZhang-NYandClaude Fable 5 50b6798467 Record T8-T9: release published, all tasks complete
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKxGW1raAWaqeU8WdHsMsp
2026-08-24 15:26:33 -04:00
ZacharyZhang-NYandClaude Fable 5 3b0f02968a Record T5-T7: build env, baseline ISO build, checksum and boot smoke test
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKxGW1raAWaqeU8WdHsMsp
4.0.0.alpha-cn.1
2026-08-24 15:10:19 -04:00
ZacharyZhang-NYandClaude Fable 5 5fa312e0ca Record T1-T4 completion and review-corrected M0 partial scope
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKxGW1raAWaqeU8WdHsMsp
2026-08-24 14:34:15 -04:00
ZacharyZhang-NYandClaude Fable 5 2c8a687434 Add OmarchyCN PRD and M0 execution task list
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKxGW1raAWaqeU8WdHsMsp
2026-08-24 14:21:55 -04:00
5d3299fb94 Wait for the keypress ourselves instead of asking gum to (#8082)
* Wait for the keypress ourselves instead of asking gum to

gum 2.0 runs a spun command without the terminal attached, so the
`gum spin -- read -n 1` that held the presentation terminal open returned
at once. Every menu command that ended in a failure took its window down
with it before the error could be read, which is how a failed update
looked like a terminal that just quit.

Read the key directly. gum's own terminal query replies are still sitting
on the tty when the spinner stops, so drain those first or they answer the
prompt on the user's behalf.

The green dot reads better than the globe did, so the provisioning notice
uses it too and drops its spinner along the way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Ask the terminal for itself before prompting on it

The /dev/tty node is there whether or not a terminal is behind it, so the
existence check passed on a headless run and left both reads failing with
"No such device or address". Open it instead.

Prompt on the terminal too, rather than stdout: a caller that redirects us
was sending the prompt to a file while the read waited on the terminal,
which looks like a hang with no instruction on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:46:02 +02:00
1565919c87 Flag a reboot when the docker group changes (#8080)
Group membership is fixed at login, so removing (or adding) the docker group
does not take effect in the running session. The existing-user migration and the
Setup > Security toggles now call `omarchy-state set reboot-required`, so
omarchy-update-restart prompts for the reboot that actually applies the change
(and the bar shows it pending). A plain log out and back in still works.

The migration test now exercises the real removal command and omarchy-state
rather than a stub, asserting the reboot flag is set on removal and left alone
when the user is already out of the group.


Claude-Session: https://claude.ai/code/session_01Gb7x6poap4hGCndPx5qt5T

Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 19:32:37 +02:00
b5ded31e2f Don't put the user in the docker group; make it opt-in (#8056)
* Don't put the user in the docker group; make it opt-in

The docker group is root-equivalent: anything in it can `docker run -v /:/host`
and rewrite the host as root with no password. On a single-user box that's not
an escalation (the owner is already a wheel/sudo user), but it hands any code
running as the user — a rogue plugin, a poisoned dependency — a silent, headless,
passwordless path to root that sudo's password prompt would otherwise gate.

Stop granting the docker group by default. The daemon still runs (docker.socket);
the Docker TUI and the Windows VM reach it through a polkit prompt, and the plain
`docker` CLI runs under sudo. Sudoless Docker is a warned opt-in via
Setup > Security (omarchy-setup-security-sudoless-docker).

No automatic path may re-grant it: install and first-boot provisioning never
record or apply the group (provisioning also filters a docker line left in an
older factory snapshot), and the Quattro upgrade no longer adds it.

The Windows VM keeps needing the root daemon for a privileged container (KVM,
NET_ADMIN), so it is reworked to run without the group and without becoming a new
way in:

- The compose lives in a root-owned dir and is only written by an elevated,
  input-validated writer. A root-invoked bring-up must never consume a file a
  user-process could rewrite to bind-mount / into the guest — the old
  ~/.config/windows compose was exactly that. Volume paths are rebuilt from
  $HOME on migration rather than trusted from the (user-writable) legacy file,
  path validation rejects traversal, and the privileged sub-action is checked
  against an allowlist before dispatch (a slash in it would otherwise run as a
  path).
- pkexec elevates a verified root-owned command path, not a PATH-resolved one,
  so an authorized prompt can't be redirected to an attacker's binary.
- The guest password is kept in a private 0600 per-user file for RDP instead of
  a world-readable compose, and a declined authorization is reported as such,
  never as a completed stop.

Existing installs auto-migrate the VM (no redownload) and refresh the stale
Docker launcher entry.

🤖 Generated by Opus 4.8 in Claude Code. Reviewed by Codex XHigh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <codex@openai.com>
Claude-Session: https://claude.ai/code/session_01Gb7x6poap4hGCndPx5qt5T

* Migrate existing installs off the docker group

The default flip only reaches new installs; existing users keep their docker
group membership and stay exposed. Extend the migration that already refreshes
the Docker launcher to also remove the current user from the group when present,
reusing omarchy-remove-security-sudoless-docker so there is one source of truth
for the change and its notice. It takes effect at next login (the current
session keeps working), and passwordless docker can be turned back on from
Setup > Security > Sudoless Docker.

Migrations run with sudo available — during `omarchy update`, or in the terminal
the pending-migrations notification opens — so the privileged removal does not
prompt at an unattended login. The no-op path (already out of the group) needs
no privilege.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gb7x6poap4hGCndPx5qt5T

* Refuse symlinked VM mount sources; correct the docker CLI docs

Review follow-ups.

valid_path keeps a traversal string (/./, //, ..) out of the compose, but it is
a string check: a symlink planted at ~/.windows or ~/Windows redirects the
privileged bind mount exactly as traversal would, because docker follows it. So
verify the mount sources as root immediately before bringing the VM up — refuse
a source that is a symlink or resolves through one — which is where the string
check cannot help. A missing source stays fine (docker creates a plain dir).

Also correct the development-tools manual: the CLI is not transparently elevated
(there is no docker wrapper and `d` is still plain docker), so say plainly that
docker on the command line takes `sudo` until sudoless Docker is enabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gb7x6poap4hGCndPx5qt5T

---------

Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Codex XHigh <codex@openai.com>
2026-08-24 18:56:00 +02:00
Ryan Hughes 5afc9e1495 Revert "Require signed packages from the Omarchy repository"
Reverts 39cffb8f, which landed on quattro through an accidental direct
push. The change returns for review as a pull request.
2026-08-24 12:53:17 -04:00
Ryan Hughes 39cffb8f51 Require signed packages from the Omarchy repository 2026-08-24 12:37:46 -04:00
4baae6bf2a Draw text as ASCII art in the Omarchy logo font (#8037)
* Add omarchy-ascii for drawing text in the logo font

Renders text as ASCII art in Delta Corps Priest 1, the FIGlet font the Omarchy wordmark itself is drawn in, so branding art can be words rather than a picture. The font is embedded in the script and the layout is done in awk, so the command adds nothing to the default package set.

The layout runs on one-byte stand-ins for the five block characters the font draws with. Column arithmetic over the characters themselves counts bytes in one locale and characters in another, and the stand-ins keep length() and substr() counting columns either way.

Delta Corps Priest 1 carries letters and spaces only, and every mirror of it ships the same file with the digit and punctuation glyphs empty. Anything else is dropped and named on stderr, and text with nothing drawable at all exits 1 rather than printing silence.

🤖 Generated by Opus 5 in Claude Code.

* Correct what the renderer did with input it could not draw

The route never ran on piped text. The metadata declared `<text...>` as required, so `omarchy ascii` with nothing on the command line resolved to the router's help while `omarchy-ascii` run directly worked, which is why the tests missed it: they all called the binary. The argument is optional now, and a test goes through the route.

Text reached awk as a command-line variable, where awk reads backslash escapes of its own, so `omarchy ascii 'A\nB'` drew two blocks instead of naming the backslash as a character the font lacks. A text longer than the argument list could not be passed at all. It arrives as awk's input now, with the font on a descriptor of its own.

A line with nothing drawable printed nothing at all, so a blank line between two words closed the gap up rather than keeping it. Every line draws its block now, blank ones included, which is what figlet does with a newline.

Placing a glyph scanned and copied the whole width of the art so far, costing the square of the line's length: four thousand characters took forty-six seconds. A row is now held without its trailing blanks, counted separately instead, so a glyph costs its own width and those four thousand characters take a tenth of a second.

A skipped control character was named on stderr by writing it out, which sends it to the terminal as a control character; those are named by code now. An unknown option was drawn as art rather than refused, so a mistyped `--width 40` quietly rendered the word "width".

figlet.c trims the column of blanks that every row of an `M` shares when it is the first glyph on a line, and figlet.js keeps it. asciiart.eu runs figlet.js, so the rendering follows figlet.js and a test pins that `M`, because the wordmark alone does not catch the difference and the next reader would have no way to tell the choice from an oversight.

🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh.

Co-Authored-By: Codex XHigh <codex@openai.com>

---------

Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Codex XHigh <codex@openai.com>
2026-08-24 17:58:52 +02:00
d99d4fc6de Remove the sudo lockout reset command (#8046)
Nothing in the repository called omarchy-sudo-reset, and its one line interpolated an environment-supplied $USER into a string handed to a root shell: `su -c "faillock --reset --user $USER"`. $USER is an environment variable rather than a kernel-supplied identity, so whatever set it before the command ran chose the rest of what root's shell executed. That is not a way past PAM on its own — su still has to authenticate — but the installer sets root's password to the user's own, so the prompt this raises is one the user answers by habit.

It bought little for that. Omarchy sets `deny=10 unlock_time=120` in /etc/pam.d/system-auth and in the lock screen's PAM stack, so a lockout takes ten wrong passwords to reach and clears itself two minutes later, and the manual documents the root-TTY reset for anyone who would rather not wait.

Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
2026-08-24 15:49:17 +02:00
20400badda Stop a psmouse quirk from failing every install (#7236)
* Stop a psmouse quirk from failing every install

install/hardware/fix-synaptic-touchpad.sh calls modprobe against the running
kernel. Under 4.0 the only thing that runs it is the ISO finalizer, inside
arch-chroot, where uname -r still names the live ISO's kernel while /lib/modules
holds the target's. The live ISO always boots linux-t2 and the configurator
gives every machine that is not a T2 Mac stock linux, so those two never match:
modprobe exits 1 with "Module psmouse not found in directory /lib/modules/<live
kernel>". run_logged returns that status and omarchy-apply-hardware runs under
set -euo pipefail, so a fresh install stops on the first machine with a device
named "synaptics" and no psmouse loaded -- reported from a ThinkPad in #6985,
but nothing about it is Lenovo-specific.

Skip the load when the running kernel's modules are not reachable, and warn
instead of failing when modprobe declines for any other reason. An optional
touchpad improvement should never be able to halt an install.

This does not make InterTouch reach the installed system: a module loaded into
the live kernel is gone at reboot, so on 4.0 this script has never applied
anything to an installed machine. Persisting it means writing options psmouse
synaptics_intertouch=1 to /etc/modprobe.d, which forces the SMBus transport past
the kernel's own allowlist on any touchpad merely named "synaptics" in
/proc/bus/input/devices. That is a hardware-behaviour change on a wide class of
machines, so it is left for a maintainer to decide separately.

Fixes #6985

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Ask modprobe whether psmouse resolves rather than guessing at /lib/modules

The reachability check ran through OMARCHY_SYNAPTIC_MODULES_DIR, an override
modprobe itself never saw: it decided whether the load was attempted but could
not change where modprobe looked, so the guard and the load consulted different
places and the seam read as though it configured module lookup. modprobe -qn
answers the same question directly -- it resolves psmouse against the running
kernel without loading it -- so the guard and the load now agree by
construction and the override goes away. The arch-chroot case that broke
installs is still skipped silently, for the same reason it always was: the live
kernel's modules are not the ones on disk.

Inline the remaining /proc/bus/input/devices override at its only use. These
leaves are sourced one after another into a single shell, so a variable left at
the top level outlives the script that set it.

Pin the wiring assertion to the run_logged call instead of any mention of the
path. A commented-out line satisfied the old grep, so the test could pass with
the quirk no longer running at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013L5zwTiZ2CsgazyxXBiPa8

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 15:33:03 +02:00
535d8f3485 Regenerate mise wrappers that still print mise's output to stdout (#8041)
omarchy-mise-install gained --quiet on its `mise use -g` line so a wrapper
no longer prints mise's "tools: pkg@version" ahead of the tool's own
output. That only changes wrappers written from then on, and the migration
that installed the current ones is already marked complete, so every
wrapper already on disk keeps polluting stdout: `claude --version` still
answers with two lines, and a wrapper for a protocol-speaking command
answers with a line its caller cannot parse.

Rewrite them through omarchy-mise-install so the template stays in one
place. Wrappers were written by four different generated forms over time
and only the ones a later migration happened to regenerate moved forward,
so all four are recognized. The whole file has to match one of them
exactly, which leaves a wrapper someone has added a line to alone and
makes a second run a no-op.


Claude-Session: https://claude.ai/code/session_01Mv1FyKG2VGZRdtknQ7YjU8

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 14:36:09 +02:00
Ryan HughesandGitHub 43bfe9b9d8 Merge pull request #7926 from basecamp/harden-notification-exec-argv
Run notification click actions as safe argv
2026-08-23 19:51:57 -04:00
Ryan Hughes 3b0d9b5734 Merge remote-tracking branch 'origin/quattro' into harden-notification-exec-argv
# Conflicts:
#	test/shell.d/notification-send-test.sh
2026-08-23 18:31:42 -04:00
Ryan Hughes 8f245e59dc Restore notify-send option parity and prove legacy fail-closed
The direct-Notify rewrite dropped notify-send options that callers rely on, which
the momus review caught: omarchy-display-text-size uses -r/-p to refresh one
toast in place, and the acceptance suite uses the --expire-time=15000 equals
form. Re-add -r/--replace-id (replaces_id), -p/--print-id (emit the returned id),
and the --flag=value form for every long option; a dash-leading description like
"-50% off" is now kept as body text rather than erroring, and --exec "" is
rejected.

Add a fixture proving the deliberate upgrade behavior: a popup persisted by a
pre-upgrade shell with a legacy `exec` shell string restores with an inert click
(execArgv empty, the old string never run) rather than executing it.
2026-08-23 17:18:58 -04:00
Ryan Hughes e3729a385b Send notifications via the Notify D-Bus method, never notify-send
omarchy-notification-send now calls org.freedesktop.Notifications.Notify
directly with `busctl --user`, instead of shelling out to notify-send. Each
value is one typed D-Bus parameter, so there is no argv/option layer that could
reinterpret a relayed headline like `--hint=…` or `-rf` as an option or a hint:
the summary and body are strings, and omarchy-exec-argv is built only from
--exec. A leading `--` keeps busctl's own getopt from reading a dash-leading
value as a busctl option.

Map -i to app_icon, -t to expire_timeout, and urgency to the byte hint; unknown
options are now a hard error rather than a silent pass-through. Route the unused
hypr o.notify helper and the sample hooks through the wrapper too, and tighten
the bin-style test so nothing under bin/ may call notify-send. The test stubs
busctl and trips if notify-send is invoked.
2026-08-23 17:02:29 -04:00
Ryan Hughes be63983d16 Describe parseExecArgv as a structural check, not intent
The comment claimed the validation kept a hostile hint from reaching a shell,
but it is purely structural: a well-formed ["bash","-c",…] passes. Say so,
and point at the separate sender-trust boundary.
2026-08-23 16:47:13 -04:00
7e469f962d Let a received Taildrop file wait to be answered (#7953)
* Let a received Taildrop file wait to be answered

A delivery can land hours after it was sent, and the toast announcing it was expiring after five seconds -- so a file that arrived while nobody was at the machine was gone from the screen before anyone could click it open. Critical urgency is what the shell reads as a popup that lives until it is clicked or dismissed, the same thing omarchy-crash-watch uses to keep its click-to-diagnose toast around.

The wrapper takes options after the headline and description as well as before, which is how this argument list is built. That path had no test, and it fails quietly rather than loudly: the wrapper appends its own default urgency last, so an urgency it stopped parsing would reach notify-send as `-u critical ... -u low` and the toast would go back to expiring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* Say it in the commit message, not above the code

`-u critical` next to a line that builds a notification says what it does, and the five lines explaining why it is there were a recap of the change rather than something the code could not say. The reasoning stays where it belongs, in the commit that made the change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
2026-08-23 22:12:07 +02:00
Claude Opus 5 (1M context)andCodex XHigh 1b15120d27 Keep relayed text out of notify-send's option parser
The argv click command closed injection through the hint's value, but the
sender still handed the headline and description to notify-send bare. A
value beginning with a dash is parsed there as flags, and one shaped like
`--hint=string:omarchy-exec-argv:[...]` is read as a hint of its own --
libnotify keys hints in a hash table, so the later of two replaces the
earlier and a forged headline outranks the vector --exec built.

That is reachable without any --exec in sight: omarchy-tailscale-send
passes a single file's basename verbatim as the description, so a file
named like the hint gives its click action to whoever chose the name.

Put the headline and description behind a `--` so notify-send reads them
as text, and refuse any pass-through word carrying omarchy-exec-argv --
--exec is the only thing that may build a click command.

Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-08-23 21:44:06 +02:00
Ryan Hughes 21cbbf8194 Recognize --exec only after the positionals
A greedy pre-scan matched the first --exec token anywhere on the line, so an
untrusted headline or description literally equal to "--exec" could be mistaken
for the delimiter. It always failed closed (the following token became a
dash-option argv[0] that parseExecArgv rejects), but it was a latent footgun
surfaced by an adversarial review.

Detect --exec only in the trailing-option loop, after the headline/description
positionals are captured. A headline that is the string "--exec" is now kept as
text and the real trailing --exec still wins. The residual — a description
exactly equal to "--exec" losing its click action — is an inherent, harmless CLI
ambiguity for a value identical to the delimiter.
2026-08-23 14:41:56 -04:00
Ryan Hughes bf2013e6f3 Make --exec take the command as rest-of-line words
Replace --exec-arg with an ergonomic --exec that consumes the rest of the line
as the click command. The caller's shell tokenizes the words into discrete
arguments before the tool sees them, and the shell runs them as positional
parameters (never a re-parsed string), so safety is identical to the argv form
while the call sites read naturally: `--exec omarchy toggle something`.

Crucially the tool never splits a string itself — a single quoted whole-command
argument is rejected and points at the unquoted form, because whitespace-
splitting a string hands argument boundaries to whoever controls its content
(the injection we are avoiding). --exec must come last; migrate every caller.
2026-08-23 14:26:25 -04:00
David Heinemeier HanssonandClaude Opus 5 f4f3d4c71a Add plan for finishing Sunshine/Moonlight into a remote desktop
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5FKtT9KPZcx3ksyfPeXCv
2026-08-23 20:02:02 +02:00
Ryan Hughes eb988b42e6 Remove --exec entirely; --exec-arg is the only click-command form
A free-form shell-string --exec sitting next to the safe --exec-arg is a
standing invitation for the next caller to interpolate untrusted data and
reintroduce the RCE. Remove it: omarchy-notification-send --exec now errors and
points at --exec-arg, and the shell drops the omarchy-exec string hint and its
bash -lc execution path, leaving only the argv path.

Migrate the remaining string callers (the first-run invitation hooks, wifi and
welcome prompts) to --exec-arg, and update their notification mocks. Trim the
verbose security comments added along the way.
2026-08-23 13:35:02 -04:00
Ryan Hughes d2fd2e11c6 Run argv click actions through a login shell as positional params
Quickshell.execDetached(argv) ran the click target with only the shell
process's stripped environment, so GUI actions like the screenshot editor
(tensaku-edit) — resolved on the login-shell PATH the old `bash -lc` string
exec provided — stopped launching on click.

Run the argv through `bash -lc 'exec "$@"'` instead: the script text is a
constant and the arguments are passed as positional parameters, which bash
expands without re-tokenizing or re-evaluating, so injection safety is intact
while PATH and session env match the old behavior exactly.
2026-08-23 12:25:48 -04:00
Ryan Hughes 07443f3970 Run notification click actions as argv, not shell strings
The click action of a notification was a free-form shell string run through
`bash -lc`, safe only when every sender shell-quoted every interpolated value
perfectly. One slip is RCE: a hostile yt-dlp video title forged an output
record and injected an mpv option into the click command (mehmetince.net RCE,
partially addressed by #7847).

Add a parameterized transport: omarchy-notification-send gains --exec-arg
(repeatable), encoding a JSON argv into the omarchy-exec-argv hint. The shell
runs it with Quickshell.execDetached(argv) and no shell, so data an attacker
controls is only ever one argument and can never be reparsed as a command. The
shell fails closed on a malformed argv hint.

The legacy free-form --exec string is retained but honored only from Omarchy's
own omarchy-action toasts, and deprecated. Migrate all in-repo callers
(screenshot, screen recording, taildrop receive, migrate-notify, crash-watch,
yt-dlp host) to --exec-arg. Update docs and tests.
2026-08-23 12:00:03 -04:00
ef6d9e6605 Stop an installed theme from running code (#7884)
* Stop an installed theme from shipping code

`omarchy theme install <url>` clones a stranger's git repository into ~/.config/omarchy/themes, and omarchy-theme-set then copied that whole directory into the staged theme. Most of the files in a staged theme are code rather than colour: Hyprland requires hyprland.lua and gum_env.lua from it at login, Neovim loads neovim.lua at startup, and alacritty.toml, kitty.conf, foot.ini and ghostty.conf each name the program the terminal launches. Installing a theme was the same act as running its author's code, and nothing on disk distinguishes an installed theme from one the user wrote.

Stage only what a theme needs in order to be a theme: colors.toml, light.mode, the preview and unlock images, and image files under backgrounds/. Everything else is ignored, named on stderr, and generated from default/themed/*.tpl instead. Symlinks are never followed, because in an untrusted theme they point wherever the author chose. A theme older than colors.toml keeps its palette: its alacritty.toml is read for colours in a scratch directory and only the resulting colors.toml is staged, so the terminal config never lands.

The filter belongs in omarchy-theme-set rather than in omarchy-theme-install because staging is the choke point. It also covers themes installed before this change, themes copied in by hand, and files a theme gains later through `omarchy theme update`.

First-party themes under $OMARCHY_PATH/themes are unaffected. Per-theme overrides of a generated file are no longer available to user themes; the template at ~/.config/omarchy/themed/<file>.tpl replaces that, and icons.theme is the one setting with no replacement.

🤖 Generated by Opus 5 in Claude Code.

* Stop a theme URL or name being read as an option or a path

Three paths in the theme commands took an attacker-shaped string straight into git, into basename, or into rm.

`git clone "$REPO_URL"` passes the URL as the first positional argument, so a URL beginning with a dash is parsed as an option instead and the destination path becomes what git tries to clone. Pass `--` before the URL so a URL is always a URL. git also treats `<helper>::<address>` as a remote helper to run; git's own protocol.allow default already refuses `ext::`, so rejecting that shape here is a second line rather than the fix, and it keeps holding if that default ever moves. The helper name is a bare word at the very start of the URL, which is what the guard matches: an scp-style IPv6 host such as git@[2001:db8::1]:org/repo.git carries `::` of its own and still clones.

`basename "$REPO_PATH" .git` has the same problem one step later, after the scp-style prefix has been stripped: `host:-s/foo.git` leaves basename reading `-s` as an option and returning `.git` as the theme name. Take the name with `--`.

That name is then joined into a path that is about to be `rm -rf`'d, so a repo whose basename came out as `..` would take ~/.config/omarchy with it. omarchy-theme-remove had the same shape from its own argument, and omarchy-theme-set's sed/tr normalization does not stop a name containing a slash. Reject empty, anything starting with a dot, and anything containing `/` in all three, before the name reaches a path.

🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh.

Co-Authored-By: Codex XHigh <codex@openai.com>

* Re-stage the current theme for installs that already applied one

Dropping a theme's code at staging time only takes effect the next time a theme is staged. An install that already applied an extra theme keeps that theme's hyprland.lua, gum_env.lua, neovim.lua and terminal configs in ~/.local/state/omarchy/current/theme, which Hyprland requires at login and the terminals include at launch, and nothing forces a theme change — so for those installs the fix would arrive whenever the user next happened to switch themes, which may be never.

Re-stage once through omarchy-theme-refresh. First-party themes stage identically, so the cost for everyone else is a single retint during an update they are already running.

🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh.

Co-Authored-By: Codex XHigh <codex@openai.com>

* Stop a theme's unlock image republishing a file it points at

omarchy-plymouth-set-by-theme reads unlock.png straight out of ~/.config/omarchy/themes, which is an installed theme's own directory and outside the staging filter, and hands the path to omarchy-plymouth-set. That path was copied twice into world-readable /usr/share — once by the user into the Plymouth theme, and once by `sudo cp` into the SDDM theme. A symlink there was followed both times, so a theme could name a file it cannot read and have root publish it.

Refuse a symlinked logo, and copy the staged logo to SDDM instead of rereading the caller's path as root. The staged copy is made by the user, so nothing privileged opens a path the caller chose.

🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh.

Co-Authored-By: Codex XHigh <codex@openai.com>

* Limit only what an installed theme could run

Two corrections to the rule this branch introduced, both narrowing it to what it was actually for.

It applied to every theme under ~/.config/omarchy/themes, which swept up themes the user wrote themselves. Their machine, their file: a theme they wrote is theirs to fill however they like, and Omarchy's own themes were never in scope. Only a theme that came from someone else needs limiting, and the repo already knows which those are — omarchy-theme-extras calls a theme with a `.git` directory an extra and a symlink someone's working copy, because that is what `omarchy theme install` leaves behind when it clones. Use the same test.

It was also an allowlist, which dropped files that carry nothing but colour and left theme authors worse off for no gain. Drop only what can run: any `*.lua`, since Hyprland requires a theme's hyprland.lua and gum_env.lua at login and Neovim loads neovim.lua at startup; the four terminal configs, since each names the program the terminal launches; and vscode.json, whose extension field reaches `code --install-extension` and a VS Code extension is arbitrary JavaScript. Everything else an installed theme ships is kept, so btop.theme, chromium.theme, helix.toml, icons.theme, keyboard.rgb and shell.toml go back to being the theme's to set.

Symlinks are still dropped, now at any depth rather than only where an allowlist happened to look.

A denylist is wrong the moment someone adds a template and does not think about it, so the decision is forced rather than remembered: the test fails on any default/themed/*.tpl whose output is recorded as neither code nor colour, and a new terminal or a new Lua-loading editor cannot be added without classifying it.

What this does not cover, and is written down in docs/theming.md rather than implied: a theme shipped as an archive and unpacked by hand looks exactly like one the user wrote. `omarchy theme install` only takes git URLs, so the supported path is always filtered, but this marks where a theme came from and is not a sandbox.

🤖 Generated by Opus 5 in Claude Code.

* Fix what the review found

Four things, all confirmed against the source before changing anything.

The migration failed permanently when the active theme had been removed. `omarchy theme remove` deletes the directory without repointing theme.name, so the name survives, the staged copy survives, and omarchy-theme-refresh exits 1 because neither source directory exists — leaving the migration pending forever and the stale staged Lua exactly where it was, which is the one thing it existed to remove. Seed the default theme in that case: there is nothing to re-stage from, and the removal should have left a working theme behind anyway.

The staging test skipped the strict-mode header that docs/testing.md makes the contract for every shell test. Adding it means the patterns that fail on purpose have to stop being bare `cmd && fail` compounds, which errexit reads as the script itself failing; the mutations were re-run afterwards to confirm the assertions still fire rather than the run dying early and looking like something else.

The guards in omarchy-theme-install and omarchy-theme-remove had no coverage — they were checked by hand and left that way. theme-install-guards-test.sh stubs git and the themes directory and proves an option-shaped URL, a transport helper, and a name that would climb out all stop before git or rm runs, that a dash inside the path no longer becomes a basename option, and that an ordinary URL still clones and applies.

The new docs/theming.md prose was hard-wrapped, which AGENTS.md forbids for docs/. Unwrapped. The rest of that file is wrapped from before and is left alone rather than churned through this change.

🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh and Copilot.

---------

Co-authored-by: Codex XHigh <codex@openai.com>
2026-08-23 16:43:31 +02:00
b71c60fe30 [Security] Stop a video title from becoming the Download Video play command (#7847)
* Stop a video title from becoming the Download Video play command

The host parsed yt-dlp's after_move line as title plus path, so a newline in page metadata could forge the path. Clicking the toast then handed that value to mpv as options. Print only the real file, ignore anything that is not inside the download dir, and invoke mpv with --.

* Refuse downloads whose video title contains control characters

The hoodie page still offered a real hidden clip, so yt-dlp saved it even after the play-action fix. A title with newlines is not a legitimate name; abort before the download and tell the user it was refused.

* Test the forged record in the order yt-dlp emits it

The records ran forged-first and good-last, so the assertion measured recovery after
bad records rather than preservation of an already-captured path when a forged record
arrives afterwards. That is the shape a hostile title actually produces, because a
title ending in a newline closes its own record and leaves the genuine path on a line
the loop ignores. As written the assertion passed with resolve_download_file replaced
by a no-op.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Resolve the download path without dropping a trailing newline

Command substitution strips trailing newlines, so an in-directory symlink pointing at
a regular file whose name ends in one canonicalised to a different path -- which may
itself exist -- and that path then passed the containment check and reached ffmpeg and
the click command. Reading realpath's NUL-terminated output keeps the name intact, and
a resolved path carrying a control character is refused outright.

Not reachable through a yt-dlp download, since --restrict-filenames strips control
characters from the name it writes; it is the helper's contract that was wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* Accept a download directory that resolves to /

realpath returns "/" for the root directory, which made the containment pattern "//*"
and rejected every file saved directly under it, so the host reported a failed download
after saving the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* Stop refusing a download because its title has control characters

The gate cannot tell a hostile title from a legitimate one. --print emits one line per
extracted video and --no-playlist does not collapse a multi_video result, so a page
holding two clips arrives as two titles separated by a newline and is refused exactly
like a forged record would be.

It also guaranteed nothing it was read as guaranteeing. The simulate run and the
download run are separate fetches, so a site is free to answer them differently, and
the check never constrained the metadata the download actually used.

What stands between a record and the click command is resolve_download_file, which is
untouched here. Leaving a check that refuses valid pages while securing nothing invites
the path validation to be relaxed later on the strength of it. A gate that would work
is possible -- --print '%(title)j' encodes each title as JSON on its own line, which
separates a newline in the metadata from a newline between videos -- but it belongs
with a use for the title rather than as a bare refusal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* Disarm the legacy exec-before-download hook too

--no-exec clears the modern --exec map but leaves --exec-before-download stored
separately, and yt-dlp restores it as a before_dl postprocessor, so a hook configured
in the user's yt-dlp config still ran during the download this host drives.

The accompanying test runs download_url itself against stubbed tools. Everything else
in this file exercises the helpers in isolation, which left the invocation uncovered:
restoring the title to the record template, or dropping --no-exec or the trailing --,
passed every assertion here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* Toast the page title again instead of the saved filename

Deriving the toast text from the sanitised filename cost the title it was meant to
show: "My Great Clip" arrived as "My_Great_Clip [My_Great_Clip]". The title is safe as
notification text -- it is an argv element, never part of a command -- so the only
question was getting it out of yt-dlp without reopening the record forgery.

It now comes from the download run, so it describes the file that was actually saved,
and it is printed as %(title)j. JSON-encoding is what makes that safe: a newline or tab
in page metadata becomes an escape sequence inside one quoted string rather than a
record boundary, so a title can no longer split itself across lines. The decoder keeps
only what precedes the first control character, refuses anything notify-send would read
as an option, and leaves the filename-derived title as the fallback when a page offers
nothing usable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Name the saved file after the page title

The download landed as "My_Great_Clip [My_Great_Clip].mp4" when the page called it
"My Great Clip". --restrict-filenames was carrying more weight than it earns here: it
folds spaces to underscores and strips non-ASCII, which is what mangles the name, and
it is not what keeps the record stream safe. yt-dlp removes control characters from a
filename either way -- a newline becomes a space, tabs and DEL and NUL are dropped --
so a path printed after the move is still only ever one line, which is the property
resolve_download_file depends on.

Dropping the [%(id)s] suffix is the other half of matching the title, and it trades
away the uniqueness that suffix bought: two videos sharing a title now share a name,
and yt-dlp skips a download whose file already exists, so the second one toasts as a
failure. Restoring the suffix is a one-line change if that trade is the wrong way
round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Omabot <omabot@omarchy.org>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
2026-08-23 16:18:38 +02:00
David Heinemeier Hansson 19266119ea Clipboard history needs to be bigger! 2026-08-23 11:49:20 +02:00
1cc5c72e2c Add Ori as a lazy-loaded agent and a default-agent choice (#7709)
Ori is OpenRouter's harness: `ori claude`, `ori codex` and `ori opencode` start those agents against OpenRouter's model catalogue, and `ori code` is Ori's own agent. That last one is what the default-agent entry launches, bare — Ori has no approval prompt to skip, so there is no "don't stop to ask" flag to pass it the way the other agents get one.

The package is `github:OpenRouterLabs/ori-releases`, because upstream ships prebuilt binaries as release assets and publishes nothing to npm. mise's `github` backend picks the right asset per platform and verifies GitHub's artifact attestations on the way in; `ubi` resolves the same release but is deprecated for removal in mise 2027.1.

The menu glyph at U+E909 is OpenRouter's own mark. Ori publishes no logo of its own and its product page renders that one, so there was no Ori-specific mark to prefer over it.

Co-authored-by: Codex XHigh <noreply@openai.com>
2026-08-23 11:44:23 +02:00
2c247e390e Stop the About logo moving when the animation starts (#7786)
* Ask the terminal whether the layout scrolled

Whether the logo is on the rows the frames address was decided by arithmetic: count the lines of a second fastfetch run, and animate if the window has one more. That holds only while the second run and the real one agree, and they need not — a module that appears between them, a wrap the measurement never sees, anything that makes the painted layout a row taller than the counted one. The screen then scrolls, the logo sits a row above where these frames draw it, and the animation paints a copy of the logo one row below the real one.

fastfetch has just painted when the question is asked, so the terminal already knows the answer: an unscrolled layout leaves the cursor one row past its last line. Ask for it, and keep the arithmetic for a terminal that will not say.

The reply has to be read without a command substitution around it, or the query goes into the substitution's own pipe instead of to the terminal, and what comes back is the escape rather than a row.

🤖 Generated by Opus 5 in Claude Code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Find the logo in the render instead of assuming where it is

The row and column the frames draw on came from the padding in this repo's fastfetch config. The config that runs is the one in /etc, and `omarchy dev link` says plainly that it does not replace files at fixed system paths — so the two can disagree, and nothing in the code would notice. A logo fastfetch drew on row 1 or row 5, redrawn on row 3, is a logo that jumps the moment the animation starts and stays where the animation put it.

fastfetch's own output is already being captured to measure the layout's height, so the logo can be found in it: take the longest line of the file as a landmark, look for it in the render, and turn where it was found back into where the logo starts. Padding of any size lands correctly, and a render that does not contain the file's own text is not this logo at all — a config that restyled it, a placeholder that was substituted — so there is nothing to animate and it is left alone.

That last part subsumes what the config-path check was guessing at. It is kept, because it also decides whether the window may be fitted, which happens before there is a terminal to measure anything in.

🤖 Generated by Opus 5 in Claude Code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Take the cursor query back out

Asking the terminal where the cursor came to rest was meant to see a scrolled layout rather than infer one. It cannot: a linefeed at the bottom margin scrolls the screen and leaves the cursor on the bottom row, so a render that fitted exactly and one that scrolled both answer with the same number. Measured on a real terminal — a 29-line layout in windows of 30, 29 and 28 rows answers 30, 29, 28. The guard therefore only ever agreed with the arithmetic it replaced, and only in the case the arithmetic already had right.

What it did add was a read on the same stdin the keypress that closes About arrives on. A key pressed while the query is outstanding is swallowed; a reply that arrives after the timeout is read by the next tick as the keypress, and the window closes on its own; typed text of the right shape is accepted as an answer, because nothing checked for the introducer; and the query is written before the read turns echo off, so a fast reply can be echoed onto the screen.

None of that buys anything the line count did not already give, so it goes.

🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* Fit the About window with a little room to spare

The window was sized to exactly what was measured: the columns the widest module line needed, and one row past the layout's last line for the cursor. That measurement is taken once, and then the content goes on living. An uptime turns minutes into hours and hours into days, a version string grows, a module shows up after the next boot — and a window with nothing in hand clips at the right edge or scrolls the top padding away the moment any of it happens. A scrolled layout also moves the logo off the row it was drawn on, which is where a shifting logo comes from.

Two columns and a row, which is invisible on screen and enough for anything that ticks over. The remembered size in about.fit is keyed on the logo, so the first launch after this still opens at the old size and the fit corrects it there and then.

🤖 Generated by Opus 5 in Claude Code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
2026-08-22 20:18:14 +02:00
eca89f9518 Animate the About logo with a passing glint (#7768)
* Animate the About logo with a passing glint

fastfetch has no animation of its own, so the sweep is ours. Every frame is composed once up front and a tick writes one of them, repainting only the cells fastfetch drew the logo on, which holds 40fps for under 1% of a core and never reaches the module column six columns to its right.

The logo is handed back exactly as it arrived: frames carry the colour fastfetch drew it in rather than a plain reset, so between glints the window is byte-for-byte the one it was before this change. Where the logo on screen might not be the text in the file, it stays still rather than guess — a fastfetch config in any directory searched ahead of Omarchy's own, a logo built from $1 colour placeholders or from a tab fastfetch expands itself, a window too small for the layout to fit without scrolling, and a shell whose locale is counting bytes instead of characters.

A resize moves those cells, so a WINCH trap ends the sweep on the frame it happens rather than up to a poll later, which measured 10ms against 830ms. The polling stays as the backstop for a signal that arrived while it could not be taken.

🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* Give the sheen a file of its own

The launcher was carrying two things: what a glint looks like, and what it is drawn over. Only the second is about fastfetch. bin/omarchy-logo-sheen now knows how to lean a band of light across an ASCII logo and nothing about About, and it is handed where the logo sits, what colour to give its cells back, and how much room it has. When a frame may be written stays with the launcher, because that is inseparable from how the window closes and resizes.

The tests split along the same seam, and the sheen's half no longer strips the launcher's tail to reach the code it tests.

🤖 Generated by Opus 5 in Claude Code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Leave a logo still when one character is not one cell

The frames slice the logo by character and the terminal draws it by column, so the sheen only puts a row back where it found it while those two agree. A double-width glyph, a combining mark and a joined emoji each break that, and a cut through one of them renders as something else — pushing the rest of the row right, into the module column, with nothing on screen to say the logo moved. A user can put any of the three in the logo by editing the text.

Comparing the row's character count against its width in columns is the assumption itself, so it is what gets checked, rather than a list of the glyph classes that happen to break it today.

🤖 Generated by Opus 5 in Claude Code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Size the About window from the layout fastfetch drew

The fit predicted the content height from the logo and the module column, taking the taller of the two. Once the logo is the taller one fastfetch writes a row more than that arithmetic expects, so every logo of 27 rows or more got a window one row short and scrolled its top padding away. The shipped logo is 26 rows, which is why it never showed.

Ask fastfetch how tall the layout came out instead of predicting it. That measurement was already being taken for the sheen, which refuses to animate a layout that scrolled — so a tall logo used to lose the glint as well as the padding row.

🤖 Generated by Opus 5 in Claude Code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Leave the logo still when NO_COLOR asks for none

fastfetch drops the logo's colour when it writes to a terminal that set NO_COLOR, but not when it writes to the substitution that measures it. The colour to hand those cells back in was therefore measured as green while the logo on screen had none, and the first glint would have left the whole logo green. It stops suppressing autowrap there too, so a narrow window soft-wraps and scrolls where the measurement cannot see it and the frames go on addressing rows that moved.

Both follow from animating a screen drawn in a mode the measurement did not reproduce. A glint is colour besides, which is the thing NO_COLOR asks for none of.
🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* Read fastfetch's config paths whole, and do not read silence as an answer

Splitting each listed path on whitespace dropped everything after the first space, so a config under a home directory containing one was missed and the fit and the sheen went ahead against a layout neither had measured. The marker fastfetch prints beside the config it settled on is not part of the path either.

A listing that failed was also indistinguishable from one that found nothing, because the status of a process substitution is discarded, so a fastfetch that could not enumerate read as "no custom config". Fall back to the directory fastfetch has always looked in first rather than take silence for an answer.
🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* Notice a resize that lands while the grid is being read

The sweep read the WINCH flag before the grid rather than after it. A signal arriving during the two command substitutions that read the grid is delivered only once they finish, so the flag was still false when it was tested, the grid still compared equal, and one more frame went out at coordinates the resize had already moved. Read the flag last, after the check it could have landed during.
🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* Check that the render loop is what plays the sheen

Every builder the tests drive can be exercised while nothing on screen ever animates. Replacing the render loop's animated branch with the still one it replaced left all of them passing, so nothing was holding the animation onto the screen it belongs on.
🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* Name the animation after the branding it animates

omarchy-logo-sheen sat in a group of its own, and a group whose only command is hidden still gets a header printed for it with nothing underneath. It belongs beside omarchy-branding-about, whose art it animates: the group already exists, the two halves of About branding are next to each other, and `omarchy branding about` still routes to the command rather than the helper now sharing its prefix.

The tests take the names of what they cover, the way the rest of the suite does.

🤖 Generated by Opus 5 in Claude Code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Fewer moving parts in the sheen

Four questions about a logo turned out to be one. Whether the shell is counting characters or bytes, whether a tab or an escape is in the line, whether a glyph is double-width or a combining mark or a joined emoji — each was asking whether one character is one cell, so that is the only thing asked now. It is also less strict in the one case that deserves it: plain ASCII art animates in a C locale, where the locale probe used to refuse everything.

One band of light instead of a white core inside a green halo, which is three colour spans a row rather than five and no helper to clamp four cuts with. Only the left cut needs clamping at all: a slice starting past the end of a line is already empty, while a negative offset would count from the end of it.

One loop instead of two, because an empty frame list plays nothing. A logo that cannot be animated now waits in the loop the animated one rests in rather than in a second copy of it, and the build that failed leaves no frames behind, since that loop plays whatever it finds.

The logo's colour comes out of one match rather than a loop eating one escape at a time.

🤖 Generated by Opus 5 in Claude Code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Say in the manual which art the glint leaves alone

The line promised the glint to any art in the file, and the code does not: art whose characters are not one column wide is left still on purpose, as is the whole screen for anyone keeping a fastfetch config of their own. Somebody reading the old line and seeing a still logo would take deliberate behaviour for a broken feature.

Name the condition rather than the list of glyph classes behind it, and say that both modes of Set From Image meet it, because that is the path almost everyone is on.

🤖 Generated by Opus 5 in Claude Code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Draw the band in a colour bold cannot collapse

A terminal can be told to render bold text in a brighter colour — foot's bold-text-in-bright, whose palette-based form brightens a bold regular colour into its bright counterpart. fastfetch draws the logo bold green, so under that setting the logo is already bright green, which is the colour the band was using: the glint came out the same green as the art and nothing appeared to happen at all. None of the four terminal configs set it, so it was waiting on whoever turned it on.

Bright white instead, because no regular colour brightens into it, and the band shows whatever the terminal does with bold. Narrower with it, since white against green needs less width to read than a second green did.

🤖 Generated by Opus 5 in Claude Code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
2026-08-22 17:19:23 +02:00
07fccef41c Add Super + Q as a second chord for closing a window (#7767)
* Add Super + Q as a second chord for closing a window

Super + W stays the documented default. Super + Q is the chord people
arrive with from macOS, where Command + Q quits the app, and typing it
into Omarchy did nothing at all until now.

🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh.

* Put an action's alternative chord on one keybindings row

Super + W and Super + Q both read "Close window" in the menu, two rows
apart, with nothing to say they were the same thing -- and the
alternative sorted above the default. The scratchpad and the calculator
had the same trouble, each bound to a chord and to a second key.

Four actions are named as having an alternative, one at a time, and the
second chord joins the first one's row. 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. Both halves still have to agree on what they dispatch,
since a label is only what a chord is called, and an unresolved
dispatcher never counts as agreement.

Nothing is allowed past the 35-character column: a pair that would
overrun it stays as two rows rather than pushing its arrow out of line.
The menu elides a row that outgrows its card -- 754px of label, 78
monospace characters at the heading size -- and the longest entry already
sits at 74, so widening the column to fit the widest pair would have cost
two dozen rows the end of their description.

Priority ordering reads the rendered row, so the chord sharing it would
otherwise reclassify the entry: XF86Calculator alone belongs in the tail
kept for media keys, and it took the calculator down there with it.
Ranking now reads the chord that leads the row.

The key left of 1 reads as ~ rather than Hyprland's name for it, whether
a bind names it or reports the keycode for the keymap to resolve. Cached
records predate all of this, so the cache version moves with it.

🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh.

Co-authored-by: Codex XHigh <noreply@openai.com>

---------

Co-authored-by: Codex XHigh <noreply@openai.com>
2026-08-22 16:55:01 +02:00
13a969e1ab Only offer Update > Extra Themes when there is one (#7775)
* Only offer Update > Extra Themes when there is one

omarchy-theme-update pulls the themes under ~/.config/omarchy/themes that came from a git clone, so on a machine that has never installed one by hand the row opens a terminal that prints nothing and closes. Guard it with the same predicates the command itself applies, since a row that shows over a symlinked theme or a worktree's `.git` file is the same dead end in a narrower shape, and pin the two to each other in the guard test.

Co-Authored-By: Codex XHigh <noreply@openai.com>

* Extract the Extra Themes guard into omarchy-theme-extras

The row's `when:` and omarchy-theme-update each carried their own idea of which themes came from a git clone, and the two only matched because a test held them together. Name it once instead: omarchy-theme-extras lists those directories and exits nonzero when there are none, so the row asks exactly the command its action runs. Living in a script also puts the glob out of reach of whatever shopt a login shell left set for the guard batch.

Co-Authored-By: Codex XHigh <noreply@openai.com>

---------

Co-authored-by: Codex XHigh <noreply@openai.com>
2026-08-22 16:24:21 +02:00
2c593dbbaa Switch back to the packaged quickshell now that 0.3.1 kills synchronously (#7769)
* Switch back to the packaged quickshell now that 0.3.1 kills synchronously

Omarchy shipped the quickshell-git build for a single fix: 0.3.0's `kill` returned before the instance had exited, so the kill loop in omarchy-restart-shell could race a dying shell. Upstream 0.3.1 ships that fix, which makes extra/quickshell the better package to be on again — signed, versioned, and not rebuilt from a moving branch on every update.

The migration swaps unconditionally instead of first checking which version the mirror offers. A machine left holding quickshell-git while the shipped package list names quickshell has no way to reconcile the two: omarchy-reinstall-pkgs installs that list with --needed, which does not skip a name that is not installed, and the conflict it then walks into has no answer under --noconfirm. A mirror that is briefly behind installs 0.3.0 instead and the next upgrade carries it to 0.3.1, which is much the cheaper way to be wrong.

🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh.

Co-Authored-By: Codex XHigh <codex@openai.com>

* Drop the quickshell version note from the shell restart loop

The comment qualified the kill loop as needing 0.3.1 or newer, but omarchy-restart-shell ships in the same package upgrade that brings quickshell along, so a machine running this code already has the version the loop depends on. The caveat could never be false where it was read, which left it as version archaeology rather than something the code could not say for itself.

🤖 Generated by Opus 5 in Claude Code.

---------

Co-authored-by: Codex XHigh <codex@openai.com>
2026-08-22 15:02:12 +02:00
David Heinemeier Hansson c539f036aa Explain things better
Given Finley's confusion
2026-08-22 14:12:14 +02:00
David Heinemeier Hansson 9d93807594 Tooltip for better discovery of alt date formats 2026-08-22 13:56:22 +02:00