Compare commits

..
29 Commits
Author SHA1 Message Date
David Heinemeier Hansson 8fcc9d6048 Merge branch 'master' into rc 2026-07-20 14:05:35 -07:00
David Heinemeier Hansson bc4eb93f59 Package name is now neovim 2026-07-20 09:21:21 -07:00
David Heinemeier HanssonandClaude Fable 5 94305c9284 Fix Neovim theme symlink pointing at the Omarchy 4 theme location (#6318)
* Address upgrade migration review feedback

* Only promote firmware during kernel transitions

* Remove the dropped packages preventing installation

* Fix Neovim theme symlink pointing at the Omarchy 4 theme location

omarchy-nvim briefly created ~/.config/nvim/lua/plugins/theme.lua linked
to ~/.local/state/omarchy/current, which doesn't exist on Omarchy 3.x,
so fresh installs errored on nvim startup with "Failed to load
plugins.theme" (#6309). Retarget the link to ~/.config/omarchy/current
unless the user has already customized it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 22:34:08 -07:00
David Heinemeier HanssonandClaude Fable 5 8e43c97610 Fix Neovim theme symlink pointing at the Omarchy 4 theme location (#6318)
* Address upgrade migration review feedback

* Only promote firmware during kernel transitions

* Remove the dropped packages preventing installation

* Fix Neovim theme symlink pointing at the Omarchy 4 theme location

omarchy-nvim briefly created ~/.config/nvim/lua/plugins/theme.lua linked
to ~/.local/state/omarchy/current, which doesn't exist on Omarchy 3.x,
so fresh installs errored on nvim startup with "Failed to load
plugins.theme" (#6309). Retarget the link to ~/.config/omarchy/current
unless the user has already customized it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 21:59:10 -07:00
David Heinemeier Hansson bec26eeab9 Merge branch 'rc' 2026-07-14 15:32:08 -07:00
David Heinemeier Hansson c8d0032d81 Fix Foot text bindings migration 2026-07-14 15:31:19 -07:00
David Heinemeier Hansson fe30a07db2 Remove the dropped packages preventing installation 2026-07-14 11:11:07 -07:00
David Heinemeier Hansson 0b22d6c138 Remove the dropped packages preventing installation 2026-07-13 17:47:59 -07:00
David Heinemeier Hansson 5f3a8d45c9 New waybar won't die without a pkill -9 2026-07-13 17:45:28 -07:00
David Heinemeier Hansson 6aa2aec1c0 Merge pull request #6208 from basecamp/3-8-3
Omarchy 3.8.3
2026-07-13 15:56:11 -07:00
David Heinemeier Hansson 96fb7df477 Only promote firmware during kernel transitions 2026-07-13 12:49:16 -07:00
David Heinemeier Hansson 08f2d86031 Address upgrade migration review feedback 2026-07-13 12:40:59 -07:00
David Heinemeier Hansson 9791b2279c Make package removal ignore providers 2026-07-12 19:32:20 -07:00
David Heinemeier Hansson f1fa7f6944 Nudge XPS 13 text scaling down just ever so slightly 2026-07-12 17:05:02 -07:00
DiegoandClaude Opus 4.8 749a8c0466 Drop fixed unit name from power-profile udev rule to prevent wakeup failures
On USB-C-only laptops, multiple power_supply udev events fire at once on
resume, and each tries to create the same --unit=omarchy-power-profile
transient unit, so all but the first fail with "unit already loaded" and log
an error on every wakeup. Drop the fixed unit name so each event gets an
auto-generated one; powerprofilesctl set is idempotent, so concurrent runs are
harmless, and --collect still garbage-collects each unit on exit.

Closes #6091.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 08:15:48 -07:00
Zeus-DeusandClaude Opus 4.8 e891e5c22f Bundle vconsole.conf in the initramfs so Plymouth uses the right keymap at the LUKS prompt
Plymouth 26 reads the keyboard layout from /etc/vconsole.conf, but the keymap
hook only bakes in the compiled keymap.bin, not the config file, so the LUKS
prompt falls back to US QWERTY. Add FILES+=(/etc/vconsole.conf) to
omarchy_hooks.conf so the initramfs carries it. New installs pick it up via
limine-snapper.sh; existing installs get it through a migration that rebuilds
the UKI. Additive — keymap.bin is unchanged, so no extra lockout risk.

Closes #6183. Related to #6151 and #6072.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 08:00:13 -07:00
Sai SridharandClaude Opus 4.8 da46ad859a Backfill Mesa Vulkan drivers for systems installed before vulkan.sh
Systems installed before vulkan.sh (2026-02-20) never received their Mesa
Vulkan ICD. Add a migration replaying vulkan.sh's GPU vendor detection and
idempotent omarchy-pkg-add calls to backfill Intel/AMD/Asahi drivers.

Closes #6199. Reported in #6089.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 07:49:12 -07:00
David Heinemeier Hansson efcd780580 Append the terminal feature instead of just ovewrwritting the 3 2026-07-12 07:38:10 -07:00
David Heinemeier Hansson 5bba34d4d3 Add mup alias for updating mise without the release age guard 2026-07-11 22:34:11 -07:00
David Heinemeier Hansson 54410563bb Add cy alias for codex 2026-07-11 22:34:00 -07:00
David Heinemeier HanssonandClaude Opus 4.8 ffa8102113 Widen sof-firmware install to all Intel SOF audio platforms
Adapts basecamp/omarchy#6200. The sof-audio-pci-intel-* driver family
(Arrow Lake, Meteor Lake, Tiger Lake, Alder Lake, and more) needs
sof-firmware to boot the DSP, but mainline linux only optdeps it, so
fresh installs came up with no audio (Dummy Output only).

Replace the Panther/Wildcat Lake GPU checks with omarchy-hw-intel-sof,
which detects any Intel PCI audio controller. Drop the XPS exclusion
since omarchy-pkg-add is idempotent and the check is harmless there.
Remove the now-orphaned omarchy-hw-intel-wcl detector and backfill
existing installs via migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 22:30:47 -07:00
David Heinemeier Hansson f36f577d16 Merge branch 'master' into 3-8-3 2026-07-11 22:26:35 -07:00
David Heinemeier Hansson e081054786 Bump version 2026-07-11 22:25:08 -07:00
David Heinemeier HanssonandClaude Opus 4.8 22a484db9c Add migration for easier tmux pane controls
Updates existing users' terminal and tmux configs to match ca19e2de:
Alt+Enter/Alt+Shift+Enter splits and Alt+Escape kill in tmux, plus
CSI-u Shift+Enter encodings across alacritty, foot, ghostty, and kitty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 22:21:22 -07:00
David Heinemeier Hansson dc804616ee Make sure Intel wildcat machines get sof-firmware for working audio 2026-07-11 22:14:44 -07:00
David Heinemeier Hansson ab92126cce Use titles for better remote server identification with hyprland groups 2026-07-11 21:56:09 -07:00
David Heinemeier Hansson ca19e2dead Add easier tmux pane controls 2026-07-11 21:55:43 -07:00
David Heinemeier Hansson 8e03151647 Fix compatibility of old tiling.conf with HL 0.55+
Closes #5973
2026-05-25 14:51:07 +02:00
David Heinemeier HanssonandRyan Hughes c3f35c57fb Omarchy 3.8.2 (#5960)
* Fix logs uploading

* Fail hard if we don't get the right branch

* Fix HL 0.55 compatible unified clipboard hotkeys

* Bump version

---------

Co-authored-by: Ryan Hughes <ryan@heyoodle.com>
2026-05-24 12:53:51 +02:00
2056 changed files with 14181 additions and 129562 deletions

No files matched your search

-4
View File
@@ -1,4 +0,0 @@
# Python bytecode (orchestrator)
__pycache__/
*.pyc
-11
View File
@@ -1,11 +0,0 @@
{
"workspace": {
"library": [
"/usr/share/hypr/stubs"
],
"checkThirdParty": false
},
"diagnostics": {
"globals": ["hl"]
}
}
+71 -60
View File
@@ -1,16 +1,3 @@
# Task Guides
Deeper instructions for specific kinds of work live in `agents/skills/`. Read the
matching guide before starting:
- [`agents/skills/command-metadata.md`](agents/skills/command-metadata.md) - adding or changing commands in `bin/`
- [`agents/skills/install-scripts.md`](agents/skills/install-scripts.md) - working under `install/` or on system/user setup commands
- [`agents/skills/shell-dev.md`](agents/skills/shell-dev.md) - editing the Quickshell desktop under `shell/`
- [`agents/skills/icon-font.md`](agents/skills/icon-font.md) - adding branded glyphs to `default/fonts/omarchy/omarchy.ttf`
- [`agents/skills/acceptance-tests.md`](agents/skills/acceptance-tests.md) - writing or running graphical acceptance tests under `test/acceptance.d/`
- [`agents/skills/visual-verification.md`](agents/skills/visual-verification.md) - verifying any change with a visual effect in the running UI
- [`docs/migrations.md`](docs/migrations.md) - creating or changing migrations under `migrations/`
# Style # Style
- Two spaces for indentation, no tabs - Two spaces for indentation, no tabs
@@ -25,9 +12,7 @@ matching guide before starting:
All commands start with `omarchy-`. Prefixes indicate purpose. All commands start with `omarchy-`. Prefixes indicate purpose.
The authoritative list of user-facing command groups lives in `bin/omarchy` in `GROUP_DESCRIPTIONS`. Keep `GROUP_DESCRIPTIONS` updated when adding a new command prefix users are meant to browse to. The authoritative command group list lives in `bin/omarchy` in `GROUP_DESCRIPTIONS`. Keep `GROUP_DESCRIPTIONS` updated when adding a new command prefix.
A group whose commands are all `# omarchy:hidden=true` gets no entry. That table drives the top-level group listing on its own, so an entry there advertises the group even when every command in it is hidden. `apply-` and `provision-` are deliberately absent for that reason; both still route, and `omarchy <group>` still prints a group header without one.
Common prefixes include: Common prefixes include:
@@ -44,77 +29,103 @@ Common prefixes include:
- `theme-` - theme management - `theme-` - theme management
- `update-` - update components - `update-` - update components
Do not maintain a second exhaustive prefix list here. Consult Other current prefixes include:
`GROUP_DESCRIPTIONS` when selecting or checking a command group so this
guidance does not drift from the router.
# Runtime Environment - `ac-`, `audio-`, `battery-`, `branch-`, `brightness-`, `channel-`, `config-`, `debug-`, `dev-`, `drive-`, `first-`, `font-`, `haptic-`, `hibernation-`, `hook-`, `hyprland-`, `menu-`, `migrate-`, `notification-`, `npx-`, `plymouth-`, `powerprofiles-`, `reinstall-`, `remove-`, `screensaver-`, `show-`, `snapshot-`, `state-`, `sudo-`, `swayosd-`, `system-`, `transcode-`, `tui-`, `tz-`, `upload-`, `version-`, `voxtype-`, `webapp-`, `wifi-`, `windows-`
- `$OMARCHY_PATH` is set at the top level by the uwsm session environment and is always available to Omarchy runtime code. # Command Metadata
- Commands in `bin/` and Quickshell QML should rely on `$OMARCHY_PATH` / `Quickshell.env("OMARCHY_PATH")`; do not derive fallback paths from `HOME`, `Quickshell.shellDir`, or re-export/default `OMARCHY_PATH` manually.
# Privileged Commands Commands in `bin/` can declare CLI metadata in comments near the top of the file. `bin/omarchy` scans the first 80 lines, and tests expect command metadata to remain valid.
- Follow the "Privilege Escalation" section of `default/agents/skills/omarchy/SKILL.md`. It draws the Supported metadata keys:
`sudo`/`pkexec` line by whether the caller has a terminal to enter a password in, and the repo's
own scripts follow it.
# Git - `# omarchy:summary=...` - short help text
- `# omarchy:group=...` - command group when it differs from the filename-derived prefix
- `# omarchy:name=...` - command name within the group
- `# omarchy:args=...` - usage arguments
- `# omarchy:examples=...` - examples separated with ` | `
- `# omarchy:alias=...` / `# omarchy:aliases=...` - alternate routes
- `# omarchy:hidden=true` - hide from default command listings
- `# omarchy:requires-sudo=true` - mark commands that require sudo
- Commits should be atomic: include only one coherent change or fix, and do not mix unrelated work. Prefer explicit metadata for user-facing commands. Keep routes consistent with the filename unless there is a deliberate alias or compatibility route.
- Commit messages should be succinct and describe the change being made.
Example:
```bash
# omarchy:summary=Take a screenshot
# omarchy:group=capture
# omarchy:args=[smart|region|windows|fullscreen] [slurp|copy]
# omarchy:examples=omarchy screenshot | omarchy capture screenshot region
# omarchy:aliases=omarchy screenshot
```
# Install Scripts
Install entry points (`install.sh`, `boot.sh`) use `#!/bin/bash`. Many scripts under `install/` are sourced via `run_logged` and intentionally do not have shebangs.
Install stage files follow this pattern:
- `install/*/all.sh` lists scripts in execution order
- leaf scripts are sourced by `run_logged $OMARCHY_INSTALL/path/to/script.sh`
- avoid `exit` in sourced install scripts unless intentionally aborting the install
- use `$OMARCHY_INSTALL` and `$OMARCHY_PATH` instead of hard-coded Omarchy paths
- keep hardware-specific logic under `install/config/hardware/`
- prefer helper commands for package and command checks where available
Raw `command -v`, `pacman`, and `pacman-key` are acceptable in bootstrap/preflight/package-helper contexts where the helper commands may not be available yet or where direct package-manager behavior is the point of the script.
# Helper Commands # Helper Commands
Use these instead of raw shell commands: Use these instead of raw shell commands:
- `omarchy-cmd-missing` / `omarchy-cmd-present` - check for commands - `omarchy-cmd-missing` / `omarchy-cmd-present` - check for commands
- `omarchy-pkg-missing` / `omarchy-pkg-present` - check for packages (don't use these if you can just use `omarchy-pkg-add`/`omarchy-pkg-drop`) - `omarchy-pkg-missing` / `omarchy-pkg-present` - check for packages
- `omarchy-pkg-add` - install packages (handles both pacman and AUR) - `omarchy-pkg-add` - install packages (handles both pacman and AUR)
- `omarchy-pkg-drop` - remove packages; use this instead of raw `pacman -R*`
- `omarchy-notification-send` - send desktop notifications; do not call `notify-send` directly
- `omarchy-hw-asus-rog` - detect ASUS ROG hardware (and similar `hw-*` commands) - `omarchy-hw-asus-rog` - detect ASUS ROG hardware (and similar `hw-*` commands)
Commands installed by Omarchy's default package set are runtime invariants. Invoke them directly; do not add defensive `omarchy-cmd-present` / `omarchy-cmd-missing` checks around them. Use command-presence helpers only for genuinely optional dependencies or code that can run before the default package set is installed. Exceptions are allowed for bootstrap, preflight, migration, and package-helper scripts where the helper may not be available yet, where the helper itself is being implemented, or where direct package-manager behavior is required.
Exceptions are allowed for migration and package-helper scripts where the helper may not be available yet, where the helper itself is being implemented, or where direct package-manager behavior is required.
# Menu
- The menu definition lives in `default/omarchy/omarchy-menu.jsonc`.
- Do not add `aliases` to new menu entries. Aliases are reserved for
established alternate names users already type, kept for compatibility.
# Config Structure # Config Structure
- `config/` - default configs copied to `~/.config/` - `config/` - default configs copied to `~/.config/`
- `default/themed/*.tpl` - templates with `{{ variable }}` placeholders for theme colors - `default/themed/*.tpl` - templates with `{{ variable }}` placeholders for theme colors
- `themes/*/colors.toml` - theme color definitions (accent, background, foreground, red/green/yellow/blue/magenta/cyan and bright_* variants) - `themes/*/colors.toml` - theme color definitions (accent, background, foreground, color0-15)
# Tests # Visual Changes
Run focused automated tests for the area you changed. Current test entry points: When making visual changes, such as Waybar styles or desktop appearance, always take and analyze a screenshot after applying the change to verify the result. Use `omarchy capture screenshot fullscreen save` for fullscreen screenshots.
- `./test/all` - aggregate runner for CLI and shell tests; it intentionally does not run graphical acceptance tests
- `./test/cli` - CLI routing, command metadata, theme helpers, and safe dispatch coverage
- `./test/shell` - all Omarchy shell tests under `test/shell.d/`
New Omarchy shell tests should live in `test/shell.d/*-test.sh` so `./test/shell` picks them up automatically. Source `test/shell.d/base-test.sh` for shared root-path discovery, assertions, and Node test helpers.
The graphical acceptance suite runs in a disposable VM, not in the active
development session; see [`agents/skills/acceptance-tests.md`](agents/skills/acceptance-tests.md).
Visual changes must be verified in the running UI in addition to automated
tests; follow [`agents/skills/visual-verification.md`](agents/skills/visual-verification.md).
# Refresh Pattern # Refresh Pattern
To copy a default config to user config with automatic backup: To copy a default config to user config with automatic backup:
```bash ```bash
omarchy-refresh-config hypr/hyprland.lua omarchy-refresh-config hypr/hyprlock.conf
``` ```
This copies `$OMARCHY_PATH/config/hypr/hyprland.lua` to `~/.config/hypr/hyprland.lua`. The argument This copies `~/.local/share/omarchy/config/hypr/hyprlock.conf` to `~/.config/hypr/hyprlock.conf`.
is interpolated into both paths and only checked with `[[ -e ]]`, so pass a plain relative path: a
name containing `..` resolves and copies, landing outside `~/.config` rather than being rejected. # Migrations
To create a new migration, run `omarchy-dev-add-migration --no-edit`. This creates a migration file named after the unix timestamp of the last commit.
New migration format:
- File permissions must be `0644` (`-rw-r--r--`); migrations are sourced, not executed directly
- No shebang line
- Start with an `echo` describing what the migration does
- Use `$OMARCHY_PATH` to reference the omarchy directory
- Prefer helper commands such as `omarchy-cmd-present`, `omarchy-cmd-missing`, `omarchy-pkg-present`, and `omarchy-pkg-missing`
Some older migrations predate these rules. Do not copy older migrations that start with shebangs, omit the leading `echo`, or hard-code `~/.local/share/omarchy`.
Migrations may use raw `pacman`, `command -v`, or direct config edits when needed for historical compatibility or one-off repair work.
Example:
```bash
echo "Disable fingerprint in hyprlock if fingerprint auth is not configured"
if omarchy-cmd-missing fprintd-list || ! fprintd-list "$USER" 2>/dev/null | grep -q "finger"; then
sed -i 's/fingerprint:enabled = .*/fingerprint:enabled = false/' ~/.config/hypr/hyprlock.conf
fi
```
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
-70
View File
@@ -4,76 +4,6 @@ Omarchy is a beautiful, modern & opinionated Linux distribution by DHH.
Read more at [omarchy.org](https://omarchy.org). 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.
- [Welcome to Omarchy!](manual/01-welcome-to-omarchy.md)
**The Basics**
- [Getting Started](manual/02-getting-started.md)
- [Coming From Mac or Windows](manual/03-coming-from-mac-or-windows.md)
- [Navigation](manual/04-navigation.md)
- [The top bar](manual/05-the-top-bar.md)
- [Themes](manual/06-themes.md)
- [Hotkeys](manual/07-hotkeys.md)
- [Unified Clipboard & History](manual/08-unified-clipboard-history.md)
- [Reminders](manual/09-reminders.md)
- [Notices](manual/10-notices.md)
- [Text Extraction & Dictation](manual/11-text-extraction-dictation.md)
- [Screenshots & Recording](manual/12-screenshots-recording.md)
- [Toggles, idle & screensaver](manual/13-toggles-idle-screensaver.md)
- [Omarchy CLI](manual/14-omarchy-cli.md)
**The Applications**
- [Terminal](manual/15-terminal.md)
- [Neovim](manual/16-neovim.md)
- [AI](manual/17-ai.md)
- [Development Tools](manual/18-development-tools.md)
- [Shell Tools](manual/19-shell-tools.md)
- [Shell Functions](manual/20-shell-functions.md)
- [TUIs](manual/21-tuis.md)
- [GUIs](manual/22-guis.md)
- [Browsers](manual/23-browsers.md)
- [Commercial apps/services](manual/24-commercial-apps-services.md)
- [Web Apps](manual/25-web-apps.md)
- [Gaming](manual/26-gaming.md)
- [Filling out PDFs](manual/27-filling-out-pdfs.md)
- [Windows VM](manual/28-windows-vm.md)
- [Other Packages](manual/29-other-packages.md)
**Configuration**
- [Updates](manual/30-updates.md)
- [Dotfiles](manual/31-dotfiles.md)
- [Shell plugins](manual/32-shell-plugins.md)
- [Monitors](manual/33-monitors.md)
- [Keyboard, Mouse, Trackpad](manual/34-keyboard-mouse-trackpad.md)
- [Networking](manual/35-networking.md)
- [System sleep](manual/36-system-sleep.md)
- [Hardware authentication](manual/37-hardware-authentication.md)
- [Fonts](manual/38-fonts.md)
- [Backgrounds](manual/39-backgrounds.md)
- [Prompt](manual/40-prompt.md)
- [Branding](manual/41-branding.md)
- [Common tweaks](manual/42-common-tweaks.md)
- [Making your own theme](manual/43-making-your-own-theme.md)
**The Rest**
- [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 ## License
Omarchy is released under the [MIT License](https://opensource.org/licenses/MIT). Omarchy is released under the [MIT License](https://opensource.org/licenses/MIT).
-46
View File
@@ -1,46 +0,0 @@
# Acceptance Tests
Read this before writing or running the graphical acceptance suite under
`test/acceptance.d/`.
The graphical acceptance suite lives in `test/acceptance` with test files under
`test/acceptance.d/*-test.sh`. It exercises a real installed Omarchy desktop,
including session health, shell surfaces, panels, keyboard navigation,
representative applications, and system setup. Source
`test/acceptance.d/base-test.sh` for the shared helpers.
Run acceptance tests in a disposable VM through the sibling `omarchy-iso`
repository, not in the active development session. The suite opens and closes
applications and temporarily changes desktop configuration.
For acceptance-test-only changes, reuse an installed base and sync the suite:
```bash
cd ../omarchy-iso
./bin/omarchy-iso-test release/<iso>.iso --reuse-base --sync-omarchy ../omarchy --no-preview
```
Use `--sync-all ../omarchy` instead of `--sync-omarchy ../omarchy` when the
acceptance run must exercise local `bin/`, `config/`, or `shell/` source too.
Changes to package manifests, installation, finalization, or shipped defaults
require a fresh ISO built from the local checkouts and a run without
`--reuse-base`:
```bash
cd ../omarchy-iso
./bin/omarchy-iso-make --no-boot-offer --local-source ../omarchy ../omarchy-pkgs
./bin/omarchy-iso-test release/<generated-iso>.iso --no-preview
```
Keep unrelated acceptance workflows in separate test files. The runner records
a failed file and continues with the remaining files, which preserves as much
diagnostic coverage as possible. Restore modified user state with traps, close
anything the test opens, and capture every visually distinct state (including
entered input where relevant) as `success-<step>.png`; failure helpers capture
`failure-<step>.png`. The ISO harness collects the screenshots and logs under
its timestamped `test-runs/` directory and opens the screenshots after the run
unless `--no-preview` is passed.
The ISO harness exercises compositor-level shortcuts with QMP virtual keyboard
input. In-guest `wtype` is suitable for typing into focused controls, but it
does not reliably prove that a global Hyprland keybinding works.
-31
View File
@@ -1,31 +0,0 @@
# Command Metadata
Read this before adding or changing commands in `bin/`.
Commands in `bin/` can declare CLI metadata in comments near the top of the
file. `bin/omarchy` scans the first 80 lines, and tests expect command metadata
to remain valid.
Supported metadata keys:
- `# omarchy:group=...` - override the command group inferred from the filename
- `# omarchy:name=...` - override the command name inferred from the filename
- `# omarchy:summary=...` - short help text
- `# omarchy:args=...` - usage arguments
- `# omarchy:examples=...` - examples separated with ` | `
- `# omarchy:alias=...` / `# omarchy:aliases=...` - alternate routes
- `# omarchy:hidden=true` - hide from default command listings
- `# omarchy:requires-sudo=true` - mark commands that require sudo
Only use `omarchy:examples` where there are args that need explaining.
Prefer explicit metadata for user-facing commands. Keep routes consistent with
the filename unless there is a deliberate alias or compatibility route.
Example:
```bash
# omarchy:summary=Take a screenshot
# omarchy:args=[smart|region|windows|fullscreen] [slurp|copy]
# omarchy:examples=omarchy screenshot | omarchy capture screenshot region
```
-75
View File
@@ -1,75 +0,0 @@
# Omarchy Icon Font
Read this before adding a branded glyph to `default/fonts/omarchy/omarchy.ttf`.
The Omarchy icon font is a small private-use font carrying the marks Nerd
Fonts does not have: the Omarchy logo and the agent and app brand marks. The
menu draws one by naming the font on an entry:
```jsonc
"setup.default.agent.grok": {"icon":"","iconFont":"omarchy","label":"Grok", ...}
```
Without `iconFont`, an entry's `icon` is drawn in the menu font, so reach for
this font only when a Nerd Font glyph would misrepresent the thing. A generic
robot for four different AI apps is the case that justifies a real mark; a
folder or a microphone is not.
`default/fonts/omarchy/README.md` lists every glyph with the URL its artwork
came from. Keep that list accurate — it is the only record of provenance.
## Adding a glyph
`omarchy dev font` does the work:
```bash
omarchy dev font list
omarchy dev font add ollama https://simpleicons.org/icons/ollama.svg
```
`add` fetches the SVG, scales it into the same 64..960 box the existing marks
use so it lands at their optical size, appends it at the next free private-use
codepoint, and adds a line to the font README. It prints the codepoint and the
glyph itself.
The source must be a **monochrome SVG with a single `<path>`**, because the
menu recolors the glyph with the active theme's foreground and selection
colors. Brand icon sets such as <https://simpleicons.org> publish exactly that
shape. App favicons usually do not: they are multi-color, carry a container
tile, or split across several paths. Prefer the official mark when it is
published as flat monochrome art, and fall back to an icon set's redraw when it
is not.
Two-tone marks are a trap. Every path becomes solid foreground, so a logo whose
meaning depends on lighter and darker halves turns into an unreadable blob.
Pick a source whose silhouette alone reads.
## After adding
The command prints these, and all of them matter:
- Point the menu entry at the new codepoint with `"iconFont":"omarchy"`.
- Bump the charset range asserted in `test/shell.d/menu-test.sh`; that test
pins the font's coverage and fails until it matches.
- Check the README line the command added, and give it a proper display name
with `--label` if the glyph name is not the brand's name.
The font is package-owned: `omarchy-settings` installs it to
`/usr/share/fonts/omarchy/omarchy.ttf`, so a new glyph reaches the desktop
through a settings release, not through `omarchy update`. Between the merge and
that release, a pulled checkout renders the new entry with no icon.
## Verifying
Render the whole font and look at it, which catches inverted contours and
filled counters that a glyph list cannot show:
```bash
python3 -c "open('/tmp/row.txt','w').write(' '.join(chr(c) for c in range(0xE900, 0xE910)))"
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). 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.
-20
View File
@@ -1,20 +0,0 @@
# Install Scripts
Read this before working under `install/` or on the system/user setup commands.
The ISO owns installation orchestration. This repo ships target-side setup
commands and reusable setup leaves:
- `bin/omarchy-apply-system` runs root-owned system setup during ISO finalization.
- `bin/omarchy-apply-hardware` runs idempotent hardware-specific setup and is called by `omarchy-apply-system`.
- `bin/omarchy-finalize-user` runs the per-user runtime finalization (skill symlinks, xdg-user-dirs, mime defaults, `install/user/all.sh`). Shipped user defaults are seeded by `/etc/skel` from `omarchy-settings`, not by this command. `bin/omarchy-reinstall-configs` is the explicit destructive resync of those defaults into an existing user's `$HOME`.
- leaf scripts under `install/` are sourced by `run_logged $OMARCHY_INSTALL/path/to/script.sh` and intentionally do not have shebangs.
- avoid `exit` in sourced setup scripts unless intentionally aborting setup.
- 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
contexts where direct package-manager behavior is the point of the script.
-47
View File
@@ -1,47 +0,0 @@
# Omarchy Shell Development
Read this before editing the Quickshell desktop under `shell/`.
The Quickshell desktop runs as a single long-running process out of
`shell/`. Hyprland autostart launches it directly with `quickshell -n -p`;
do not start additional standalone Quickshell instances for individual
components.
Run `omarchy-restart-shell` after making changes to QML files.
## Plugin contract
- First-party plugins live directly under `shell/plugins/` or one category
level deeper, such as `shell/plugins/panels/weather/`. First-party bar-only
widgets may use adjacent `*.manifest.json` files. Third-party plugins live
at `~/.config/omarchy/plugins/<id>/` with a `manifest.json` at the root.
- Every plugin manifest declares `schemaVersion`, `id`, `name`, `version`,
`kinds`, and `entryPoints`. See
[`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. 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`.
## IPC
- `bin/omarchy-shell` is the canonical IPC entry point. It forwards to
the running shell and does not start it. Prefer it over re-implementing
direct Quickshell socket calls in every CLI.
- The `shell` IPC target exposes lifecycle and configuration methods including
`ping`, `summon`, `hide`, `toggle`, `call`, `rescanPlugins`, `reloadConfig`,
`setPluginEnabled`, and `listPlugins`. `shell.qml` also registers
`image-selector`, which drives the `omarchy.image-picker` panel.
- Individual plugins register their own IPC targets, named for the plugin rather
than for where they appear: the background switcher registers `background`, and
bar widgets register one target each — `omarchy.indicators`,
`omarchy.system-update`, `omarchy.clock`. There is no `bar` target.
## Editing widget files with glyphs
Widget files in `shell/plugins/bar/widgets/` contain Nerd Font glyphs as raw
unicode characters. Agent file-editing tools can strip multi-byte codepoints
in some positions — do **not** rewrite widget files wholesale through those
tools. For glyph fixes, make a targeted edit with the surrounding context, or
use a Python script that inserts codepoints via `chr(0xXXXXX)`.
-44
View File
@@ -1,44 +0,0 @@
# Visual Verification
Read this before finishing any change with a visual effect: Omarchy shell
styling and layout, panels, menus, notifications, desktop appearance,
animations, transitions, screenshots, and screen recording flows.
Visual changes must be verified in the running UI in addition to automated
tests. Creating an artifact is not sufficient: inspect it for clipping,
overlap, incorrect spacing, stale state, focus problems, and visual
regressions before finishing.
Take a full-screen screenshot without opening the editor:
```bash
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.
Record a short full-screen video for animation, transition, timing, capture, or
screen-recording changes:
```bash
omarchy screenrecord --fullscreen
# Exercise the changed behavior.
omarchy screenrecord --stop-recording
```
The stop command prints the saved video path in the configured Videos
directory. Review the recording before finishing, and keep it short and focused
on the changed behavior.
For interactive UI work, use `wtype` to simulate keyboard input when available.
Example: start the UI in the background, wait briefly for focus, then run
`wtype -k Right -k Return` to exercise keyboard selection and confirm the
resulting command output or state change. Prefer this over manual-only
verification when a UI returns a selected value or changes a symlink/config.
If a launched UI would otherwise remain open, keep track of its PID and stop it
after the screenshot or recording; avoid broad process kills unless checking
with `ps` first.
File renamed without changes.
-8
View File
@@ -1,8 +0,0 @@
[Desktop Entry]
Version=1.0
Name=Basecamp
Exec=omarchy-launch-webapp https://launchpad.37signals.com
Terminal=false
Type=Application
Icon=basecamp
StartupNotify=true
-8
View File
@@ -1,8 +0,0 @@
[Desktop Entry]
Version=1.0
Name=Discord
Exec=omarchy-launch-webapp https://discord.com/channels/@me
Terminal=false
Type=Application
Icon=omarchy-discord
StartupNotify=true
-8
View File
@@ -1,8 +0,0 @@
[Desktop Entry]
Version=1.0
Name=Disk Usage
Exec=xdg-terminal-exec --app-id=TUI.float -e bash -c "dua i /"
Terminal=false
Type=Application
Icon=disk-usage
StartupNotify=true
-8
View File
@@ -1,8 +0,0 @@
[Desktop Entry]
Version=1.0
Name=Docker
Exec=xdg-terminal-exec --app-id=TUI.tile -e omarchy-launch-docker-tui
Terminal=false
Type=Application
Icon=docker
StartupNotify=true
-8
View File
@@ -1,8 +0,0 @@
[Desktop Entry]
Version=1.0
Name=Google Contacts
Exec=omarchy-launch-webapp https://contacts.google.com/
Terminal=false
Type=Application
Icon=google-contacts
StartupNotify=true
-8
View File
@@ -1,8 +0,0 @@
[Desktop Entry]
Version=1.0
Name=Google Maps
Exec=omarchy-launch-webapp https://maps.google.com
Terminal=false
Type=Application
Icon=google-maps
StartupNotify=true
-8
View File
@@ -1,8 +0,0 @@
[Desktop Entry]
Version=1.0
Name=Google Messages
Exec=omarchy-launch-webapp https://messages.google.com/web/conversations
Terminal=false
Type=Application
Icon=google-messages
StartupNotify=true
-8
View File
@@ -1,8 +0,0 @@
[Desktop Entry]
Version=1.0
Name=Google Photos
Exec=omarchy-launch-webapp https://photos.google.com/
Terminal=false
Type=Application
Icon=google-photos
StartupNotify=true
-9
View File
@@ -1,9 +0,0 @@
[Desktop Entry]
Version=1.0
Name=HEY
Exec=omarchy-webapp-handler-hey %u
Terminal=false
Type=Application
Icon=hey
StartupNotify=true
MimeType=x-scheme-handler/mailto
-8
View File
@@ -1,8 +0,0 @@
[Desktop Entry]
Version=1.0
Name=WhatsApp
Exec=omarchy-launch-webapp https://web.whatsapp.com/
Terminal=false
Type=Application
Icon=whatsapp
StartupNotify=true
-8
View File
@@ -1,8 +0,0 @@
[Desktop Entry]
Version=1.0
Name=X
Exec=omarchy-launch-webapp https://x.com/
Terminal=false
Type=Application
Icon=x
StartupNotify=true
-8
View File
@@ -1,8 +0,0 @@
[Desktop Entry]
Version=1.0
Name=YouTube
Exec=omarchy-launch-webapp https://youtube.com/
Terminal=false
Type=Application
Icon=youtube
StartupNotify=true
-9
View File
@@ -1,9 +0,0 @@
[Desktop Entry]
Version=1.0
Name=Zoom
Exec=omarchy-webapp-handler-zoom %u
Terminal=false
Type=Application
Icon=zoom
StartupNotify=true
MimeType=x-scheme-handler/zoommtg;x-scheme-handler/zoomus
File renamed without changes.
+2
View File
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
+2
View File
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
+2
View File
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
+2
View File
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
+2
View File
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
+2
View File
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
+2
View File
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
+2
View File
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
+2
View File
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
+2
View File
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
+2
View File
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
+2
View File
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
+2
View File
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
+2
View File
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
+2
View File
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
+2
View File
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
+2
View File
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
+2
View File
@@ -0,0 +1,2 @@
[Desktop Entry]
Hidden=true
Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

File renamed without changes.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

+10
View File
@@ -0,0 +1,10 @@
[Desktop Entry]
Name=Typora
GenericName=Markdown Editor
Exec=typora --enable-wayland-ime %U
Icon=typora
Type=Application
StartupNotify=true
Categories=Office;WordProcessor;
MimeType=text/markdown;text/x-markdown;
+48 -76
View File
@@ -26,49 +26,32 @@ declare -A ROUTE_IS_ALIAS
declare -A BINARY_TO_KEY declare -A BINARY_TO_KEY
declare -A GROUP_DESCRIPTIONS declare -A GROUP_DESCRIPTIONS
GROUP_DESCRIPTIONS[agent]="AI coding agent usage data" GROUP_DESCRIPTIONS[ac]="AC power detection"
GROUP_DESCRIPTIONS[audio]="Audio input and output controls"
GROUP_DESCRIPTIONS[bar]="Omarchy shell bar layout and settings"
GROUP_DESCRIPTIONS[battery]="Battery status helpers" GROUP_DESCRIPTIONS[battery]="Battery status helpers"
GROUP_DESCRIPTIONS[bluetooth]="Bluetooth device controls"
GROUP_DESCRIPTIONS[branch]="Omarchy git branch management" GROUP_DESCRIPTIONS[branch]="Omarchy git branch management"
GROUP_DESCRIPTIONS[branding]="About and screensaver branding" GROUP_DESCRIPTIONS[branding]="About and screensaver branding"
GROUP_DESCRIPTIONS[brightness]="Display and keyboard brightness" GROUP_DESCRIPTIONS[brightness]="Display and keyboard brightness"
GROUP_DESCRIPTIONS[capture]="Screenshots and screen recording" GROUP_DESCRIPTIONS[capture]="Screenshots and screen recording"
GROUP_DESCRIPTIONS[channel]="Omarchy release channel management" GROUP_DESCRIPTIONS[channel]="Omarchy release channel management"
GROUP_DESCRIPTIONS[clipboard]="Clipboard helpers"
GROUP_DESCRIPTIONS[cmd]="Command and shortcut helpers" GROUP_DESCRIPTIONS[cmd]="Command and shortcut helpers"
GROUP_DESCRIPTIONS[config]="System configuration helpers" GROUP_DESCRIPTIONS[config]="System configuration helpers"
GROUP_DESCRIPTIONS[debug]="Diagnostics and support logs" GROUP_DESCRIPTIONS[debug]="Diagnostics and support logs"
GROUP_DESCRIPTIONS[finalize]="Finalize user setup"
GROUP_DESCRIPTIONS[default]="Default application selection" GROUP_DESCRIPTIONS[default]="Default application selection"
GROUP_DESCRIPTIONS[dev]="Omarchy development tools" GROUP_DESCRIPTIONS[dev]="Omarchy development tools"
GROUP_DESCRIPTIONS[disk]="Disk performance helpers"
GROUP_DESCRIPTIONS[display]="Display and text scaling"
GROUP_DESCRIPTIONS[dns]="DNS resolver configuration"
GROUP_DESCRIPTIONS[drive]="Drive selection and encryption" GROUP_DESCRIPTIONS[drive]="Drive selection and encryption"
GROUP_DESCRIPTIONS[file]="File selection helpers"
GROUP_DESCRIPTIONS[font]="Font management" GROUP_DESCRIPTIONS[font]="Font management"
GROUP_DESCRIPTIONS[games]="Game launchers and helpers"
GROUP_DESCRIPTIONS[hibernation]="Hibernation setup and removal" GROUP_DESCRIPTIONS[hibernation]="Hibernation setup and removal"
GROUP_DESCRIPTIONS[hook]="User hook runner" GROUP_DESCRIPTIONS[hook]="User hook runner"
GROUP_DESCRIPTIONS[hw]="Hardware detection and controls" GROUP_DESCRIPTIONS[hw]="Hardware detection and controls"
GROUP_DESCRIPTIONS[hyprland]="Hyprland window, monitor, and toggle controls" GROUP_DESCRIPTIONS[hyprland]="Hyprland window, monitor, and toggle controls"
GROUP_DESCRIPTIONS[install]="Optional software installers" GROUP_DESCRIPTIONS[install]="Optional software installers"
GROUP_DESCRIPTIONS[installed]="Installed optional service checks"
GROUP_DESCRIPTIONS[launch]="Application launchers" GROUP_DESCRIPTIONS[launch]="Application launchers"
GROUP_DESCRIPTIONS[menu]="Omarchy menu commands" GROUP_DESCRIPTIONS[menu]="Omarchy menu commands"
GROUP_DESCRIPTIONS[migrate]="Migration runner" GROUP_DESCRIPTIONS[migrate]="Migration runner"
GROUP_DESCRIPTIONS[monitor]="Monitor status helpers"
GROUP_DESCRIPTIONS[network]="Network status helpers"
GROUP_DESCRIPTIONS[notification]="Notification helpers" GROUP_DESCRIPTIONS[notification]="Notification helpers"
GROUP_DESCRIPTIONS[mise]="Mise tool wrappers" GROUP_DESCRIPTIONS[npx]="NPX package wrappers"
GROUP_DESCRIPTIONS[openclaw]="OpenClaw agent platform setup"
GROUP_DESCRIPTIONS[osd]="On-screen display status helpers"
GROUP_DESCRIPTIONS[pkg]="Package management helpers" GROUP_DESCRIPTIONS[pkg]="Package management helpers"
GROUP_DESCRIPTIONS[plugin]="Omarchy shell plugin and bar widget management"
GROUP_DESCRIPTIONS[plymouth]="Plymouth boot theme management" GROUP_DESCRIPTIONS[plymouth]="Plymouth boot theme management"
GROUP_DESCRIPTIONS[power]="Power supply detection"
GROUP_DESCRIPTIONS[powerprofiles]="Power profile management" GROUP_DESCRIPTIONS[powerprofiles]="Power profile management"
GROUP_DESCRIPTIONS[refresh]="Reset config to defaults" GROUP_DESCRIPTIONS[refresh]="Reset config to defaults"
GROUP_DESCRIPTIONS[reinstall]="Reinstall and reset workflows" GROUP_DESCRIPTIONS[reinstall]="Reinstall and reset workflows"
@@ -76,16 +59,16 @@ GROUP_DESCRIPTIONS[reminder]="Desktop notification reminders"
GROUP_DESCRIPTIONS[remove]="Removal workflows" GROUP_DESCRIPTIONS[remove]="Removal workflows"
GROUP_DESCRIPTIONS[restart]="Restart Omarchy components" GROUP_DESCRIPTIONS[restart]="Restart Omarchy components"
GROUP_DESCRIPTIONS[setup]="Interactive setup wizards" GROUP_DESCRIPTIONS[setup]="Interactive setup wizards"
GROUP_DESCRIPTIONS[shell]="Omarchy shell IPC helpers"
GROUP_DESCRIPTIONS[screensaver]="Screensaver branding and animation" GROUP_DESCRIPTIONS[screensaver]="Screensaver branding and animation"
GROUP_DESCRIPTIONS[snapshot]="System snapshots" GROUP_DESCRIPTIONS[snapshot]="System snapshots"
GROUP_DESCRIPTIONS[sudo]="Sudo configuration helpers" GROUP_DESCRIPTIONS[sudo]="Sudo configuration helpers"
GROUP_DESCRIPTIONS[system]="System status, reboot, shutdown, logout, and lock" GROUP_DESCRIPTIONS[swayosd]="SwayOSD status display helpers"
GROUP_DESCRIPTIONS[tailscale]="Tailscale helpers" GROUP_DESCRIPTIONS[system]="Reboot, shutdown, logout, and lock"
GROUP_DESCRIPTIONS[theme]="Theme management" GROUP_DESCRIPTIONS[theme]="Theme management"
GROUP_DESCRIPTIONS[toggle]="Toggle Omarchy features" GROUP_DESCRIPTIONS[toggle]="Toggle Omarchy features"
GROUP_DESCRIPTIONS[transcode]="Image and video transcoding" GROUP_DESCRIPTIONS[transcode]="Image and video transcoding"
GROUP_DESCRIPTIONS[tui]="Terminal UI launchers" GROUP_DESCRIPTIONS[tui]="Terminal UI launchers"
GROUP_DESCRIPTIONS[tz]="Timezone selection"
GROUP_DESCRIPTIONS[update]="Omarchy and system updates" GROUP_DESCRIPTIONS[update]="Omarchy and system updates"
GROUP_DESCRIPTIONS[version]="Version and channel information" GROUP_DESCRIPTIONS[version]="Version and channel information"
GROUP_DESCRIPTIONS[voxtype]="Voxtype dictation" GROUP_DESCRIPTIONS[voxtype]="Voxtype dictation"
@@ -121,31 +104,6 @@ append_pipe_value() {
fi fi
} }
# A route can resolve to a binary before all args are consumed (`update aur` only matches the `update` binary, leaving `aur` and `--help` as leftovers)
# --help must never be lost among those leftovers and get forwarded into the real command, so this checks the whole remainder rather than just the first token.
# A `--` marks everything after it as belonging to the command itself, so scanning stops there.
remaining_has_help_flag() {
local token=""
for token in "$@"; do
[[ $token == "--" ]] && break
[[ $token == "--help" || $token == "-h" ]] && return 0
done
return 1
}
remaining_has_json_flag() {
local token=""
for token in "$@"; do
[[ $token == "--" ]] && break
[[ $token == "--json" ]] && return 0
done
return 1
}
register_route() { register_route() {
local route="$1" local route="$1"
local key="$2" local key="$2"
@@ -171,6 +129,7 @@ register_command() {
local name="" local name=""
local summary="" local summary=""
local usage="" local usage=""
local binary=""
local args="" local args=""
local examples="" local examples=""
local aliases="" local aliases=""
@@ -260,6 +219,7 @@ register_command() {
fallback_name="${fallback_name//-/ }" fallback_name="${fallback_name//-/ }"
fi fi
[[ -z $binary ]] && binary="$file_binary"
[[ -z $group ]] && group="$fallback_group" [[ -z $group ]] && group="$fallback_group"
[[ $name_seen != "true" ]] && name="$fallback_name" [[ $name_seen != "true" ]] && name="$fallback_name"
[[ -z $summary && -n $fallback_summary ]] && summary="$fallback_summary" [[ -z $summary && -n $fallback_summary ]] && summary="$fallback_summary"
@@ -269,9 +229,11 @@ register_command() {
route+=" $name" route+=" $name"
fi fi
usage="$route" if [[ -z $usage ]]; then
if [[ -n $args ]]; then usage="$route"
usage+=" $args" if [[ -n $args ]]; then
usage+=" $args"
fi
fi fi
[[ $requires_sudo == "true" ]] || requires_sudo="false" [[ $requires_sudo == "true" ]] || requires_sudo="false"
@@ -281,7 +243,7 @@ register_command() {
COMMAND_KEYS+=("$key") COMMAND_KEYS+=("$key")
COMMAND_ROUTE["$key"]="$route" COMMAND_ROUTE["$key"]="$route"
COMMAND_FALLBACK_ROUTE["$key"]="$fallback_route" COMMAND_FALLBACK_ROUTE["$key"]="$fallback_route"
COMMAND_BINARY["$key"]="$file_binary" COMMAND_BINARY["$key"]="$binary"
COMMAND_GROUP["$key"]="$group" COMMAND_GROUP["$key"]="$group"
COMMAND_NAME["$key"]="$name" COMMAND_NAME["$key"]="$name"
COMMAND_SUMMARY["$key"]="$summary" COMMAND_SUMMARY["$key"]="$summary"
@@ -293,7 +255,7 @@ register_command() {
COMMAND_HAS_SUMMARY["$key"]="$has_summary" COMMAND_HAS_SUMMARY["$key"]="$has_summary"
COMMAND_METADATA_ERRORS["$key"]="$metadata_errors" COMMAND_METADATA_ERRORS["$key"]="$metadata_errors"
BINARY_TO_KEY["$file_binary"]="$key" BINARY_TO_KEY["$binary"]="$key"
register_route "$route" "$key" register_route "$route" "$key"
register_route "$fallback_route" "$key" register_route "$fallback_route" "$key"
@@ -639,31 +601,25 @@ show_commands_markdown() {
done < <(sorted_keys "$include_all") done < <(sorted_keys "$include_all")
} }
emit_command_record() {
local key="$1"
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"${COMMAND_ROUTE[$key]}" \
"${COMMAND_BINARY[$key]}" \
"${COMMAND_GROUP[$key]}" \
"${COMMAND_NAME[$key]}" \
"${COMMAND_SUMMARY[$key]}" \
"${COMMAND_REQUIRES_SUDO[$key]}" \
"${COMMAND_HIDDEN[$key]}" \
"${COMMAND_ARGS[$key]}" \
"${COMMAND_EXAMPLES[$key]}" \
"${COMMAND_ALIASES[$key]}" \
"${COMMAND_FALLBACK_ROUTE[$key]}" \
"${COMMAND_USAGE[$key]}"
}
emit_command_records() { emit_command_records() {
local include_all="$1" local include_all="$1"
local key="" local key=""
while IFS= read -r key; do while IFS= read -r key; do
[[ -n $key ]] || continue [[ -n $key ]] || continue
emit_command_record "$key" printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"${COMMAND_ROUTE[$key]}" \
"${COMMAND_BINARY[$key]}" \
"${COMMAND_GROUP[$key]}" \
"${COMMAND_NAME[$key]}" \
"${COMMAND_SUMMARY[$key]}" \
"${COMMAND_REQUIRES_SUDO[$key]}" \
"${COMMAND_HIDDEN[$key]}" \
"${COMMAND_ARGS[$key]}" \
"${COMMAND_EXAMPLES[$key]}" \
"${COMMAND_ALIASES[$key]}" \
"${COMMAND_FALLBACK_ROUTE[$key]}" \
"${COMMAND_USAGE[$key]}"
done < <(sorted_keys "$include_all") done < <(sorted_keys "$include_all")
} }
@@ -732,7 +688,23 @@ show_commands_check() {
} }
show_command_json() { show_command_json() {
emit_command_record "$1" | jq -Rn "$(commands_json_filter) | {ok: true, command: .commands[0]}" local key="$1"
printf '%s\n' "$key" | while IFS= read -r key; do
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"${COMMAND_ROUTE[$key]}" \
"${COMMAND_BINARY[$key]}" \
"${COMMAND_GROUP[$key]}" \
"${COMMAND_NAME[$key]}" \
"${COMMAND_SUMMARY[$key]}" \
"${COMMAND_REQUIRES_SUDO[$key]}" \
"${COMMAND_HIDDEN[$key]}" \
"${COMMAND_ARGS[$key]}" \
"${COMMAND_EXAMPLES[$key]}" \
"${COMMAND_ALIASES[$key]}" \
"${COMMAND_FALLBACK_ROUTE[$key]}" \
"${COMMAND_USAGE[$key]}"
done | jq -Rn "$(commands_json_filter) | {ok: true, command: .commands[0]}"
} }
parse_commands_args() { parse_commands_args() {
@@ -954,7 +926,7 @@ dispatch_fast_or_help() {
remaining=("${args[@]:DIRECT_RESOLVED_COUNT}") remaining=("${args[@]:DIRECT_RESOLVED_COUNT}")
binary_path="$OMARCHY_BIN_DIR/$DIRECT_RESOLVED_BINARY" binary_path="$OMARCHY_BIN_DIR/$DIRECT_RESOLVED_BINARY"
if remaining_has_help_flag "${remaining[@]}"; then if (( ${#remaining[@]} > 0 )) && [[ ${remaining[0]} == "--help" || ${remaining[0]} == "-h" ]]; then
if ! load_command_by_binary "$DIRECT_RESOLVED_BINARY"; then if ! load_command_by_binary "$DIRECT_RESOLVED_BINARY"; then
echo "Binary is missing or not executable: $DIRECT_RESOLVED_BINARY" >&2 echo "Binary is missing or not executable: $DIRECT_RESOLVED_BINARY" >&2
return 127 return 127
@@ -965,7 +937,7 @@ dispatch_fast_or_help() {
fi fi
key="${BINARY_TO_KEY[$DIRECT_RESOLVED_BINARY]}" key="${BINARY_TO_KEY[$DIRECT_RESOLVED_BINARY]}"
if remaining_has_json_flag "${remaining[@]}"; then if [[ " ${remaining[*]} " == *" --json "* ]]; then
show_command_json "$key" show_command_json "$key"
else else
show_command_help "$key" show_command_help "$key"
@@ -1018,8 +990,8 @@ dispatch_or_help() {
key="$RESOLVED_KEY" key="$RESOLVED_KEY"
remaining=("${args[@]:RESOLVED_COUNT}") remaining=("${args[@]:RESOLVED_COUNT}")
if remaining_has_help_flag "${remaining[@]}"; then if (( ${#remaining[@]} > 0 )) && [[ ${remaining[0]} == "--help" || ${remaining[0]} == "-h" ]]; then
if remaining_has_json_flag "${remaining[@]}"; then if [[ " ${remaining[*]} " == *" --json "* ]]; then
show_command_json "$key" show_command_json "$key"
else else
show_command_help "$key" show_command_help "$key"
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# omarchy:summary=Returns true if AC power is connected.
for ac in /sys/class/power_supply/AC* /sys/class/power_supply/ADP*; do
[[ -r $ac/online && $(cat "$ac/online") == "1" ]] && exit 0
done
exit 1
-138
View File
@@ -1,138 +0,0 @@
#!/bin/bash
# omarchy:summary=Launch the default coding agent in a terminal
# omarchy:args=[--inline] [--pick]
# omarchy:examples=omarchy agent | omarchy agent --inline
inline=false
pick=false
# Flags only. A bare prompt would shadow the subcommands under this group, so
# prompts go through omarchy-agent-prompt, which passes one here as --prompt.
while (($#)); do
case "$1" in
--inline)
inline=true
shift
;;
--pick)
pick=true
shift
;;
--prompt)
prompt=${2:?--prompt needs a value}
shift 2
;;
*)
echo "Unexpected argument: $1" >&2
echo "To pass a prompt: omarchy agent prompt \"$*\"" >&2
exit 1
;;
esac
done
# Agents refuse to remember trust for $HOME, so launches from the keybinding or
# menu start in the work directory instead of re-asking on every session.
[[ $PWD == "$HOME" && -d $HOME/Work ]] && cd "$HOME/Work"
agent=$(omarchy-default-agent)
# Omarchy ships without a default, so there is nothing to launch until one is
# picked. --pick offers the choice instead of the error, which is what the
# keybinding wants: a keypress that opens nothing explains nothing.
if [[ -z $agent ]]; then
[[ $pick == "true" ]] && exec omarchy-menu summon setup.default.agent
echo "Choose default agent with: omarchy default agent <name>" >&2
exit 1
fi
if omarchy-cmd-missing "$agent"; then
echo "$agent is not installed. Choose an installed agent with: omarchy default agent <name>" >&2
exit 1
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.
case "$agent" in
opencode)
command=(opencode --auto)
[[ -n ${prompt:-} ]] && command+=(--prompt "$prompt")
;;
gemini)
command=(gemini --yolo)
[[ -n ${prompt:-} ]] && command+=(--prompt-interactive "$prompt")
;;
copilot)
command=(copilot --allow-all)
[[ -n ${prompt:-} ]] && command+=(--interactive "$prompt")
;;
crush)
# --yolo belongs to the interactive command only; `crush run` never prompts.
if [[ -n ${prompt:-} ]]; then
command=(crush run "$prompt")
else
command=(crush --yolo)
fi
;;
claude)
command=(claude --permission-mode auto)
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
;;
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")
;;
pi)
command=(pi)
[[ -n ${prompt:-} ]] && command+=("$prompt")
;;
*)
echo "Unsupported default agent: $agent" >&2
exit 1
;;
esac
if [[ $inline == "true" ]]; then
exec "${command[@]}"
else
# A fixed app-id rather than the default org.omarchy.<binary>, so every agent
# window shares one class for window rules and themes to single out.
exec omarchy-launch-tui --app-id=org.omarchy.agent "${command[@]}"
fi
-52
View File
@@ -1,52 +0,0 @@
#!/bin/bash
# omarchy:summary=Diagnose a crashed process with the default coding agent
# omarchy:args=<pid> [comm] [exe] [signal]
# omarchy:examples=omarchy agent crash 1516893
# Clicked from a "Process crashed:" notification, or run by hand against any PID
# in `coredumpctl list`. The method lives in the diagnose-crash skill so it is
# edited in one place and works with whichever agent is default; this only
# gathers the facts and points at it.
set -euo pipefail
pid=${1:?usage: omarchy-agent-crash <pid> [comm] [exe] [signal]}
if [[ ! $pid =~ ^[0-9]+$ ]]; then
echo "Not a PID: $pid" >&2
echo "Usage: omarchy agent crash <pid> (see: coredumpctl list)" >&2
exit 1
fi
comm=${2:-unknown}
exe=${3:-unknown}
signal=${4:-unknown}
skill="$OMARCHY_PATH/default/agents/skills/diagnose-crash/SKILL.md"
# Looked up live so a hand-run PID still gets a timestamp. A rotated-away core
# only costs the timestamp, so failure is tolerated.
when=$(coredumpctl list "$pid" --no-pager --no-legend 2>/dev/null | tail -1 | cut -d' ' -f1-4) || true
when=${when:-unknown}
prompt=$(
cat <<PROMPT
A process crashed on this Omarchy machine and I want to know why.
What systemd-coredump recorded:
process: $comm
PID: $pid
binary: $exe
signal: $signal
time: $when
Use the diagnose-crash skill: it covers how to investigate, what to report, and
when a crash is worth reporting upstream to Omarchy. If your harness has no skill
mechanism, read the skill files directly and follow them instead:
$skill
PROMPT
)
exec omarchy-agent --prompt "$prompt"
-23
View File
@@ -1,23 +0,0 @@
#!/bin/bash
# omarchy:summary=Launch the default coding agent with a prompt
# omarchy:args=[--inline] <prompt...>
# omarchy:examples=omarchy agent prompt "Review this project"
# Prompts live here rather than on `omarchy agent`, where a bare prompt would
# shadow the subcommands under that group.
set -euo pipefail
inline=()
if [[ ${1:-} == "--inline" ]]; then
inline=(--inline)
shift
fi
if (($# == 0)); then
echo "Usage: omarchy agent prompt [--inline] <prompt...>" >&2
exit 1
fi
exec omarchy-agent "${inline[@]}" --prompt "$*"
-905
View File
@@ -1,905 +0,0 @@
#!/usr/bin/python3
# omarchy:summary=Print the Claude Code usage record as JSON
# omarchy:args=[--force] [--limits-only]
# omarchy:hidden=true
"""Collect Claude Code usage into one display-ready JSON record.
Everything the agents panel shows for Claude comes from this one
command: local transcript stats from ~/.claude/projects, the stats-cache and
history fallbacks for machines without transcripts, pi/omp and opencode
sessions that ran on an Anthropic provider, and the authoritative rate
limits from Anthropic's OAuth usage endpoint. The panel itself only ever
reads the JSON this prints; it never talks to disk formats or endpoints.
"""
from __future__ import annotations
import argparse
import datetime as dt
import fcntl
import hashlib
import json
import os
import re
import sqlite3
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
AGENT_ID = "claude"
AGENT_NAME = "Claude Code"
AUTH_HELP = "Run `claude auth login` to restore authoritative usage."
USAGE_ENDPOINT = "https://api.anthropic.com/api/oauth/usage"
PROBE_MIN_INTERVAL_SECONDS = 15
def config_dir() -> Path:
return expand_path(os.environ.get("CLAUDE_CONFIG_DIR") or "~/.claude")
def expand_path(value: str) -> Path:
return Path(os.path.expandvars(os.path.expanduser(value))).resolve()
def cache_root() -> Path:
root = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "omarchy" / "agent-usage"
root.mkdir(parents=True, exist_ok=True)
return root
def date_string(value: dt.date) -> str:
return value.strftime("%Y-%m-%d")
def recent_date_strings() -> list[str]:
today = dt.datetime.now().date()
return [date_string(today - dt.timedelta(days=offset)) for offset in range(6, -1, -1)]
def local_date_string() -> str:
return date_string(dt.datetime.now().date())
def local_date_from_timestamp(value: Any) -> str:
if value is None:
return local_date_string()
if isinstance(value, (int, float)):
try:
seconds = float(value) / 1000.0 if float(value) > 10_000_000_000 else float(value)
return date_string(dt.datetime.fromtimestamp(seconds).date())
except Exception:
return local_date_string()
raw = str(value).strip()
if not raw:
return local_date_string()
# Claude JSONL timestamps are usually ISO-8601. Python accepts offsets but
# not a trailing Z until we normalize it to +00:00.
try:
parsed = dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
if parsed.tzinfo is not None:
parsed = parsed.astimezone()
return date_string(parsed.date())
except Exception:
return local_date_string()
def usage_token(usage: dict[str, Any], snake_key: str, camel_key: str) -> int:
value = usage.get(snake_key, usage.get(camel_key, 0))
try:
return round(float(value or 0))
except Exception:
return 0
def number(value: Any) -> int:
try:
n = float(value or 0)
return round(n) if n == n else 0
except Exception:
return 0
def empty_bucket() -> dict[str, int]:
return {
"inputTokens": 0,
"outputTokens": 0,
"cacheReadInputTokens": 0,
"cacheCreationInputTokens": 0,
}
# ---------------------------------------------------------------- local scan
def scan_projects(projects_path: Path) -> dict[str, Any]:
today = local_date_string()
recent_dates = recent_date_strings()
recent = {day: {"date": day, "messageCount": 0} for day in recent_dates}
seen: set[str] = set()
sessions: set[str] = set()
active_days: set[str] = set()
today_sessions: set[str] = set()
today_tokens: dict[str, int] = {}
usage_by_model: dict[str, dict[str, int]] = {}
prompts = 0
today_prompt_count = 0
today_token_total = 0
files = projects_path.rglob("*.jsonl") if projects_path.is_dir() else []
for path in files:
try:
with path.open("r", encoding="utf-8", errors="replace") as handle:
for line_number, line in enumerate(handle, 1):
# Cheap pre-filter before JSON parsing keeps files with unrelated
# lines inexpensive.
if '"usage":' not in line:
continue
try:
entry = json.loads(line)
except Exception:
continue
message = entry.get("message") if isinstance(entry.get("message"), dict) else {}
if entry.get("type") != "assistant" and message.get("role") != "assistant":
continue
usage = message.get("usage") or entry.get("usage")
if not isinstance(usage, dict):
continue
message_id = message.get("id") or entry.get("messageId") or ""
unique_key = str(message_id) if message_id else f"{path}:{entry.get('uuid') or entry.get('requestId') or line_number}"
if unique_key in seen:
continue
seen.add(unique_key)
input_tokens = usage_token(usage, "input_tokens", "inputTokens")
output_tokens = usage_token(usage, "output_tokens", "outputTokens")
cache_read = usage_token(usage, "cache_read_input_tokens", "cacheReadInputTokens")
cache_write = usage_token(usage, "cache_creation_input_tokens", "cacheCreationInputTokens")
total = input_tokens + output_tokens + cache_read + cache_write
if total <= 0:
continue
model = str(message.get("model") or entry.get("model") or "claude")
day = local_date_from_timestamp(entry.get("timestamp") or message.get("timestamp"))
session_key = str(entry.get("sessionId") or path)
sessions.add(session_key)
active_days.add(day)
prompts += 1
bucket = usage_by_model.setdefault(model, empty_bucket())
bucket["inputTokens"] += input_tokens
bucket["outputTokens"] += output_tokens
bucket["cacheReadInputTokens"] += cache_read
bucket["cacheCreationInputTokens"] += cache_write
if day in recent:
# recentDays.messageCount is actually a token total, despite the
# legacy name shared with synced snapshots.
recent[day]["messageCount"] += total
if day == today:
today_prompt_count += 1
today_sessions.add(session_key)
today_token_total += total
today_tokens[model] = today_tokens.get(model, 0) + total
except Exception as exc:
print(f"Ignoring unreadable Claude project file {path}: {exc}", file=sys.stderr)
return {
"todayPrompts": today_prompt_count,
"todaySessions": len(today_sessions),
"todayTotalTokens": today_token_total,
"todayTokensByModel": today_tokens,
"recentDays": [recent[day] for day in recent_dates],
"modelUsage": usage_by_model,
"totalPrompts": prompts,
"totalSessions": len(sessions),
# Days with any recorded usage, for the all-time "N days" summary. The
# dates travel too: merging snapshots from several machines needs their
# union, which a count alone cannot give.
"activeDays": len(active_days),
"activeDates": sorted(active_days),
}
def scan_cache_paths(projects_path: Path) -> tuple[Path, Path]:
digest = hashlib.sha1(str(projects_path).encode("utf-8")).hexdigest()[:16]
root = cache_root()
return root / f"claude-scan-{digest}.json", root / f"claude-scan-{digest}.lock"
def read_fresh_json(path: Path, max_age_seconds: float) -> dict[str, Any] | None:
if max_age_seconds <= 0 or not path.exists():
return None
try:
if time.time() - path.stat().st_mtime <= max_age_seconds:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
return None
def write_json(path: Path, payload: dict[str, Any]) -> None:
# A temp name unique to this writer, not derived from the target: several
# collectors can run at once (the update command backgrounds one per agent,
# the panel refreshes on its own), and a shared temp path means the second
# replace finds the first one's file already moved away.
handle_fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp")
tmp = Path(tmp_name)
try:
with os.fdopen(handle_fd, "w", encoding="utf-8") as handle:
handle.write(json.dumps(payload, separators=(",", ":"), sort_keys=True) + "\n")
# mkstemp opens at 0600; these caches were world-readable before.
tmp.chmod(0o644)
tmp.replace(path)
except BaseException:
tmp.unlink(missing_ok=True)
raise
def cached_scan(projects_path: Path, max_age_seconds: float) -> dict[str, Any]:
cache_file, lock_file = scan_cache_paths(projects_path)
cached = read_fresh_json(cache_file, max_age_seconds)
if cached is not None:
return cached
with lock_file.open("w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
cached = read_fresh_json(cache_file, max_age_seconds)
if cached is not None:
return cached
summary = scan_projects(projects_path)
write_json(cache_file, summary)
return summary
# ------------------------------------------------------------- local fallback
#
# A machine without transcripts on disk can still know its history: Claude
# Code keeps aggregate counters in stats-cache.json and per-prompt history in
# history.jsonl. Only consulted when the project scan comes back empty.
def stats_cache_fallback(claude_dir: Path) -> dict[str, Any] | None:
try:
data = json.loads((claude_dir / "stats-cache.json").read_text(encoding="utf-8"))
except Exception:
return None
today = local_date_string()
daily_model_tokens = data.get("dailyModelTokens") or []
today_tokens = {}
for entry in daily_model_tokens:
if isinstance(entry, dict) and entry.get("date") == today:
today_tokens = entry.get("tokensByModel") or {}
break
daily_activity = [day for day in (data.get("dailyActivity") or []) if isinstance(day, dict)]
active_dates = sorted({str(day.get("date")) for day in daily_activity if number(day.get("messageCount")) > 0 and day.get("date")})
today_prompts, today_sessions = today_prompts_from_history(claude_dir)
return {
"todayPrompts": today_prompts,
"todaySessions": today_sessions,
"todayTotalTokens": sum(number(v) for v in today_tokens.values()),
"todayTokensByModel": today_tokens,
"recentDays": daily_activity[-7:],
"modelUsage": data.get("modelUsage") or {},
"totalPrompts": number(data.get("totalMessages")),
"totalSessions": number(data.get("totalSessions")),
"activeDays": len(active_dates),
"activeDates": active_dates,
}
def today_prompts_from_history(claude_dir: Path) -> tuple[int, int]:
prompts = 0
sessions: set[str] = set()
start_of_day = dt.datetime.combine(dt.datetime.now().date(), dt.time.min).timestamp() * 1000
try:
with (claude_dir / "history.jsonl").open("r", encoding="utf-8", errors="replace") as handle:
lines = handle.readlines()
except Exception:
return 0, 0
for line in reversed(lines):
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except Exception:
continue
if number(entry.get("timestamp")) < start_of_day:
break
prompts += 1
if entry.get("sessionId"):
sessions.add(str(entry.get("sessionId")))
return prompts, len(sessions)
# --------------------------------------------------------------- pi and omp
#
# These agents can consume a Claude subscription without writing native
# Claude Code transcripts. Their compatible JSONL session formats carry the
# provider, model, and token usage on every assistant message.
def scan_pi_usage(max_age_seconds: float) -> dict[str, Any] | None:
roots = [
Path.home() / ".pi" / "agent" / "sessions",
Path.home() / ".omp" / "agent" / "sessions",
]
cache_file = cache_root() / "claude-pi-sessions.json"
cached = read_fresh_json(cache_file, max_age_seconds)
if cached is not None:
return cached.get("stats")
today = local_date_string()
recent_dates = recent_date_strings()
recent = {day: {"date": day, "messageCount": 0} for day in recent_dates}
sessions: set[str] = set()
active_days: set[str] = set()
today_sessions: set[str] = set()
today_tokens: dict[str, int] = {}
usage_by_model: dict[str, dict[str, int]] = {}
seen: set[str] = set()
prompts = 0
today_prompt_count = 0
today_token_total = 0
for root in roots:
files = root.rglob("*.jsonl") if root.is_dir() else []
for path in files:
try:
with path.open("r", encoding="utf-8", errors="replace") as handle:
for line_number, line in enumerate(handle, 1):
if '"usage"' not in line or '"assistant"' not in line:
continue
try:
entry = json.loads(line)
message = entry.get("message") if isinstance(entry.get("message"), dict) else {}
if entry.get("type") != "message" or message.get("role") != "assistant":
continue
provider = str(message.get("provider") or "")
if provider != "anthropic":
continue
unique_key = f"{path}:{entry.get('id') or line_number}"
if unique_key in seen:
continue
seen.add(unique_key)
usage = message.get("usage") or {}
input_tokens = usage_token(usage, "input", "inputTokens")
output_tokens = usage_token(usage, "output", "outputTokens")
cache_read = usage_token(usage, "cacheRead", "cache_read_input_tokens")
cache_write = usage_token(usage, "cacheWrite", "cache_creation_input_tokens")
total = input_tokens + output_tokens + cache_read + cache_write
if total <= 0:
total = number(usage.get("totalTokens"))
input_tokens = total
if total <= 0:
continue
model = str(message.get("model") or "claude")
day = local_date_from_timestamp(entry.get("timestamp") or message.get("timestamp"))
except Exception:
continue
session_key = str(path)
sessions.add(session_key)
active_days.add(day)
prompts += 1
bucket = usage_by_model.setdefault(model, empty_bucket())
bucket["inputTokens"] += input_tokens
bucket["outputTokens"] += output_tokens
bucket["cacheReadInputTokens"] += cache_read
bucket["cacheCreationInputTokens"] += cache_write
if day in recent:
recent[day]["messageCount"] += total
if day == today:
today_prompt_count += 1
today_sessions.add(session_key)
today_token_total += total
today_tokens[model] = today_tokens.get(model, 0) + total
except OSError:
continue
stats = None
if prompts > 0:
stats = {
"todayPrompts": today_prompt_count,
"todaySessions": len(today_sessions),
"todayTotalTokens": today_token_total,
"todayTokensByModel": today_tokens,
"recentDays": [recent[day] for day in recent_dates],
"modelUsage": usage_by_model,
"totalPrompts": prompts,
"totalSessions": len(sessions),
"activeDays": len(active_days),
"activeDates": sorted(active_days),
}
write_json(cache_file, {"stats": stats})
return stats
# ---------------------------------------------------------------- opencode
#
# A Claude subscription burned entirely through opencode never writes a
# transcript under ~/.claude, but opencode records per-message provider,
# model, and token usage in its own database. Scan it for Anthropic-provider
# messages and merge the result into whatever the transcript scan found.
def scan_opencode_usage(max_age_seconds: float) -> dict[str, Any] | None:
db = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share")) / "opencode" / "opencode.db"
if not db.is_file():
return None
# Same freshness contract as the transcript scan: --limits-only promises to
# reuse recent local stats, and a big opencode history walked on every panel
# open would break that promise.
cache_file = cache_root() / f"claude-opencode-{hashlib.sha1(str(db).encode('utf-8')).hexdigest()[:16]}.json"
cached = read_fresh_json(cache_file, max_age_seconds)
if cached is not None:
return cached.get("stats")
today = local_date_string()
recent_dates = recent_date_strings()
recent = {day: {"date": day, "messageCount": 0} for day in recent_dates}
sessions: set[str] = set()
active_days: set[str] = set()
today_sessions: set[str] = set()
today_tokens: dict[str, int] = {}
usage_by_model: dict[str, dict[str, int]] = {}
prompts = 0
today_prompt_count = 0
today_token_total = 0
try:
# Read-only: opencode may be writing right now.
conn = sqlite3.connect(db.resolve().as_uri() + "?mode=ro", uri=True, timeout=2)
except sqlite3.Error:
return None
try:
conn.execute("PRAGMA query_only = ON")
for session_id, raw in conn.execute("SELECT session_id, data FROM message"):
# One malformed row must not abort the scan, so every shape assumption
# lives inside the try.
try:
entry = json.loads(raw)
# Exact match: opencode provider ids are free-form, and a custom
# "anthropic-proxy" gateway is not this subscription.
if not isinstance(entry, dict) or entry.get("role") != "assistant":
continue
if str(entry.get("providerID") or "") != "anthropic":
continue
tokens = entry.get("tokens") or {}
cache = tokens.get("cache") or {}
input_tokens = number(tokens.get("input"))
# opencode keeps thinking tokens out of output; both are generated.
output_tokens = number(tokens.get("output")) + number(tokens.get("reasoning"))
cache_read = number(cache.get("read"))
cache_write = number(cache.get("write"))
total = input_tokens + output_tokens + cache_read + cache_write
if total <= 0:
continue
created = number((entry.get("time") or {}).get("created"))
day = dt.datetime.fromtimestamp(created / 1000).strftime("%Y-%m-%d") if created > 0 else today
model = str(entry.get("modelID") or "claude").rstrip("/").split("/")[-1]
except Exception:
continue
session_key = "opencode:" + str(session_id)
sessions.add(session_key)
active_days.add(day)
prompts += 1
bucket = usage_by_model.setdefault(model, empty_bucket())
bucket["inputTokens"] += input_tokens
bucket["outputTokens"] += output_tokens
bucket["cacheReadInputTokens"] += cache_read
bucket["cacheCreationInputTokens"] += cache_write
if day in recent:
recent[day]["messageCount"] += total
if day == today:
today_prompt_count += 1
today_sessions.add(session_key)
today_token_total += total
today_tokens[model] = today_tokens.get(model, 0) + total
except sqlite3.Error:
return None
finally:
conn.close()
stats = None
if prompts > 0:
stats = {
"todayPrompts": today_prompt_count,
"todaySessions": len(today_sessions),
"todayTotalTokens": today_token_total,
"todayTokensByModel": today_tokens,
"recentDays": [recent[day] for day in recent_dates],
"modelUsage": usage_by_model,
"totalPrompts": prompts,
"totalSessions": len(sessions),
"activeDays": len(active_days),
"activeDates": sorted(active_days),
}
write_json(cache_file, {"stats": stats})
return stats
def merge_stats(base: dict[str, Any], extra: dict[str, Any]) -> dict[str, Any]:
merged = dict(base)
for key in ("todayPrompts", "todaySessions", "todayTotalTokens", "totalPrompts", "totalSessions"):
merged[key] = number(base.get(key)) + number(extra.get(key))
combined = dict(base.get("todayTokensByModel") or {})
for model, count in (extra.get("todayTokensByModel") or {}).items():
combined[model] = number(combined.get(model)) + number(count)
merged["todayTokensByModel"] = combined
usage = {model: dict(bucket) for model, bucket in (base.get("modelUsage") or {}).items()}
for model, bucket in (extra.get("modelUsage") or {}).items():
target = usage.setdefault(model, empty_bucket())
for field, count in (bucket or {}).items():
target[field] = number(target.get(field)) + number(count)
merged["modelUsage"] = usage
by_date: dict[str, int] = {}
for source in (base.get("recentDays") or [], extra.get("recentDays") or []):
for day in source:
date = str((day or {}).get("date") or "")
if date:
by_date[date] = by_date.get(date, 0) + number((day or {}).get("messageCount"))
merged["recentDays"] = [{"date": date, "messageCount": by_date[date]} for date in sorted(by_date)]
# Sources overlap in time, so union dates rather than summing counts. A
# fallback that only knows a count still bounds the answer from below.
dates = set(base.get("activeDates") or []) | set(extra.get("activeDates") or [])
merged["activeDates"] = sorted(dates)
merged["activeDays"] = max(len(dates), number(base.get("activeDays")), number(extra.get("activeDays")))
return merged
# ------------------------------------------------------------------- limits
# The access token, its expiry, and the display-safe plan label from the
# CLI's login. Nothing else leaves the credential store: the token goes
# nowhere but the Authorization header of the limits probe, and only the
# plan label may travel into the printed record.
def oauth_login(claude_dir: Path) -> tuple[str, int, str]:
try:
data = json.loads((claude_dir / ".credentials.json").read_text(encoding="utf-8"))
except Exception:
return "", 0, ""
login = data.get("claudeAiOauth")
if not isinstance(login, dict):
return "", 0, ""
plan = plan_label(str(login.get("rateLimitTier") or ""), str(login.get("subscriptionType") or ""))
return str(login.get("accessToken") or ""), number(login.get("expiresAt")), plan
def plan_label(tier: str, subscription: str) -> str:
if tier:
match = re.search(r"max_(\d+x)", tier, re.IGNORECASE)
if match:
return "Max " + match.group(1)
if subscription:
return subscription[0].upper() + subscription[1:]
return ""
def parse_utilization(value: Any) -> float:
try:
return float(str(value).strip().replace("%", ""))
except Exception:
return float("nan")
def normalize_utilization(value: Any, percent_scale: bool) -> float:
n = parse_utilization(value)
if not (n >= 0):
return -1.0
# Anthropic's OAuth usage endpoint currently reports percentages (for
# example 37.0 or 1.0). Older payloads sometimes used fractions (0.37).
# A payload containing any value >= 1 is percent-scaled, so 1.0 renders
# as 1%, not 100%.
if percent_scale or n > 1:
return min(1.0, n / 100.0)
return min(1.0, n)
def normalize_reset_at(value: Any) -> str:
if value is None:
return ""
raw = str(value).strip()
if raw == "":
return ""
if raw.isdigit():
ts = int(raw)
if ts < 1e12:
ts *= 1000
try:
return dt.datetime.fromtimestamp(ts / 1000, dt.timezone.utc).isoformat()
except Exception:
return raw
try:
parsed = dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
return parsed.isoformat()
except Exception:
return raw
def usage_bucket(payload: dict[str, Any], key: str) -> dict[str, Any] | None:
bucket = payload.get(key)
return bucket if isinstance(bucket, dict) else None
# An entry's `kind` names its window the way the flat buckets' keys do
# ("weekly_scoped", "five_hour_scoped"). The panel reads a window out of free
# text, which cannot survive a model name like "Opus 5 (1M context)" — the
# "1M" reads as a one-minute window — so the window is settled here instead
# and travels as an explicit title. It is capitalized the way the flat windows
# title themselves, so "Fable Weekly" sits beside "Weekly" rather than under it.
def scoped_window(kind: str) -> str:
text = kind.lower()
if "month" in text:
return "Monthly"
if "week" in text or "day" in text:
return "Weekly"
if "hour" in text or "session" in text:
return "Session"
return ""
# Alongside the flat buckets, the payload carries a `limits` array, and that
# array is the only place a model-scoped allowance shows up — a weekly window
# that only Fable draws from, say. The matching legacy keys
# (`seven_day_opus`, `seven_day_sonnet`, …) stayed behind at null, so a
# collector that reads buckets alone silently drops a limit the account is
# actually spending against. A model can hold more than one scoped window, and
# only the pair of model and window tells them apart, so both make the title
# and both make the key that keeps a repeat out.
def scoped_limits(payload: dict[str, Any], percent_scale: bool) -> list[dict[str, Any]]:
entries = payload.get("limits")
if not isinstance(entries, list):
return []
out: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
for entry in entries:
if not isinstance(entry, dict):
continue
scope = entry.get("scope")
model = scope.get("model") if isinstance(scope, dict) else None
if not isinstance(model, dict):
continue
# A display name is what the panel wants, but an entry carrying only an id
# still names a window worth showing.
name = str(model.get("display_name") or model.get("id") or "").strip()
kind = str(entry.get("kind") or "").strip()
if name == "" or (name, kind) in seen:
continue
percent = normalize_utilization(entry.get("percent"), percent_scale)
if percent < 0:
continue
seen.add((name, kind))
window = scoped_window(kind)
title = name + " " + window if window else name
out.append({
"label": title,
"title": title,
"percent": percent,
"resetsAt": normalize_reset_at(entry.get("resets_at")),
})
return out
def probe_limits(access_token: str) -> dict[str, Any]:
request = urllib.request.Request(
USAGE_ENDPOINT,
headers={
"Authorization": "Bearer " + access_token,
"anthropic-beta": "oauth-2025-04-20",
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
payload = json.loads(response.read().decode("utf-8", errors="replace"))
except urllib.error.HTTPError as error:
retry_after = error.headers.get("retry-after", "") if error.headers else ""
if error.code == 429:
help_text = "Anthropic's usage endpoint is rate limiting checks right now" + (
f" (retry after {retry_after}s)" if retry_after else ""
) + ". Local Claude Code stats are still shown."
else:
help_text = f"Anthropic's usage endpoint returned status {error.code}. Local Claude Code stats are still shown."
return {"ok": False, "helpText": help_text}
except Exception:
# A transport failure reached no server at all — no route, no DNS. Any
# real answer, including an error status, is a server we should stop
# pestering; this is not.
return {
"ok": False,
"transport": True,
"helpText": "Couldn't reach Anthropic's usage endpoint. Retrying shortly. Local Claude Code stats are still shown.",
}
weekly = usage_bucket(payload, "seven_day_oauth_apps") or usage_bucket(payload, "seven_day")
session = usage_bucket(payload, "five_hour")
raw = [session.get("utilization") if session else None, weekly.get("utilization") if weekly else None]
# One payload speaks one convention, so the scoped entries settle the scale
# alongside the buckets rather than assuming their own.
entries = payload.get("limits")
if isinstance(entries, list):
raw += [entry.get("percent") for entry in entries if isinstance(entry, dict)]
percent_scale = any(parse_utilization(v) >= 1 for v in raw)
limits = []
if session is not None:
percent = normalize_utilization(session.get("utilization"), percent_scale)
if percent >= 0:
limits.append({"label": "Session (5-hour)", "percent": percent, "resetsAt": normalize_reset_at(session.get("resets_at"))})
if weekly is not None:
percent = normalize_utilization(weekly.get("utilization"), percent_scale)
if percent >= 0:
limits.append({"label": "Weekly (7-day)", "percent": percent, "resetsAt": normalize_reset_at(weekly.get("resets_at"))})
limits.extend(scoped_limits(payload, percent_scale))
if not limits:
return {"ok": False, "helpText": "Anthropic's usage endpoint returned no limits. Local Claude Code stats are still shown."}
return {"ok": True, "limits": limits}
# A cached percentage outlives the probe that measured it, but only until its
# window rolls over: once a window has reset, the figure describes a period
# that is over, and a stale 78% would misreport an allowance that is now
# untouched. A window with no reset time, or one that will not parse, is kept
# — an unreadable timestamp is no reason to throw away a real number.
def limit_window_open(entry: dict[str, Any], now: dt.datetime) -> bool:
raw = str(entry.get("resetsAt") or "")
if raw == "":
return True
try:
resets_at = dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
except Exception:
return True
if resets_at.tzinfo is None:
resets_at = resets_at.replace(tzinfo=dt.timezone.utc)
return resets_at > now
def usable_cached_limits(cached: dict[str, Any]) -> list[dict[str, Any]]:
entries = cached.get("limits")
if not isinstance(entries, list):
return []
now = dt.datetime.now(dt.timezone.utc)
return [entry for entry in entries if isinstance(entry, dict) and limit_window_open(entry, now)]
def collect_limits(access_token: str, expires_at_ms: int, force: bool) -> dict[str, Any]:
result = {"limits": [], "usageStatusText": "", "authHelpText": AUTH_HELP}
# A panel that is opened and shut repeatedly must not turn into a request
# per flick, so recent probe results are reused for a short window — and
# kept as the answer of record when a later probe fails.
probe_cache = cache_root() / "claude-limits.json"
cached = read_fresh_json(probe_cache, float("inf")) or {}
fallback = usable_cached_limits(cached)
# Probing needs a live token and only the Claude Code CLI can mint one: it
# refreshes the credential file when it runs, so a machine left alone long
# enough finds the saved token lapsed. Say so — an empty limits list with
# nothing else set hides the whole section and explains nothing — and keep
# showing the last numbers whose window has not since reset.
if access_token == "":
result["limits"] = fallback
result["usageStatusText"] = "Waiting for auth"
return result
if expires_at_ms > 0 and expires_at_ms <= time.time() * 1000:
result["limits"] = fallback
result["usageStatusText"] = "Sign-in expired"
result["authHelpText"] = (
"Claude Code's saved sign-in expired"
+ (" — showing the last known limits." if fallback else ".")
+ " Start Claude Code, or run `claude auth login`, to refresh it."
)
return result
# --force is a person asking for fresh numbers, so it skips the reuse window
# entirely; the interval is there to absorb repeated panel opens, not to
# overrule someone who pressed refresh.
fetched_at = number(cached.get("fetchedAtMs")) / 1000
if fallback and not force and time.time() - fetched_at < PROBE_MIN_INTERVAL_SECONDS:
result["limits"] = fallback
return result
probe = probe_limits(access_token)
if probe["ok"]:
result["limits"] = probe["limits"]
write_json(probe_cache, {"fetchedAtMs": round(time.time() * 1000), "limits": probe["limits"]})
return result
# The first probe after login often fires before DHCP has handed out a
# route. Ask the shell to try again sooner than its regular interval.
if probe.get("transport"):
result["retryAdvised"] = True
if fallback:
result["limits"] = fallback
else:
result["usageStatusText"] = "Claude limits unavailable"
result["authHelpText"] = probe["helpText"]
return result
# -------------------------------------------------------------------- record
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--force", action="store_true", help="rescan transcripts and re-probe limits, ignoring caches")
parser.add_argument("--limits-only", action="store_true", help="reuse any recent transcript scan; only the limits probe must be fresh")
parser.add_argument("--cache-seconds", type=float, default=20)
args = parser.parse_args()
claude_dir = config_dir()
scan_age = 0 if args.force else (900 if args.limits_only else args.cache_seconds)
stats = cached_scan(claude_dir / "projects", scan_age)
if number(stats.get("totalPrompts")) <= 0:
fallback = stats_cache_fallback(claude_dir)
if fallback is not None:
stats = fallback
else:
# No transcripts and no aggregate cache, but history.jsonl alone can
# still put numbers on today.
today_prompts, today_sessions = today_prompts_from_history(claude_dir)
if today_prompts or today_sessions:
stats = dict(stats, todayPrompts=today_prompts, todaySessions=today_sessions)
pi_usage = scan_pi_usage(scan_age)
if pi_usage is not None:
stats = merge_stats(stats, pi_usage)
opencode = scan_opencode_usage(scan_age)
if opencode is not None:
stats = merge_stats(stats, opencode)
access_token, expires_at_ms, plan = oauth_login(claude_dir)
limits = collect_limits(access_token, expires_at_ms, args.force)
record = {
"schemaVersion": 1,
"id": AGENT_ID,
"name": AGENT_NAME,
"updatedAt": dt.datetime.now(dt.timezone.utc).isoformat(),
"ready": number(stats.get("totalPrompts")) > 0 or len(limits["limits"]) > 0,
"hasLocalStats": True,
"tierLabel": plan,
"usageStatusText": limits["usageStatusText"],
"authHelpText": limits["authHelpText"],
"limits": limits["limits"],
}
if limits.get("retryAdvised"):
record["retryAdvised"] = True
record.update(stats)
print(json.dumps(record, separators=(",", ":"), sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
-603
View File
@@ -1,603 +0,0 @@
#!/usr/bin/python3
# omarchy:summary=Print the Codex usage record as JSON
# omarchy:args=[--force] [--limits-only]
# omarchy:hidden=true
"""Collect Codex usage into one display-ready JSON record.
Local stats come from native Codex CLI session files, pi/omp sessions that
ran through openai-codex, and opencode sessions that ran on an OpenAI
provider; rate limits and the plan come from the Codex app-server RPC. The agents
panel only ever reads the JSON this prints.
"""
import argparse
import fcntl
import hashlib
import json
import os
import select
import shutil
import sqlite3
import subprocess
import sys
import tempfile
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
AGENT_ID = "codex"
AGENT_NAME = "Codex"
AUTH_HELP = "Run `codex login` to authenticate."
# A scan this recent is only reused to dedup concurrent collector runs (the
# update command backgrounds one per agent while the panel refreshes on its
# own); every periodic widget refresh lands a real rescan, however low
# refreshIntervalSec is set. --limits-only promises only fresh limits, so it
# may reuse a scan for up to 15 minutes.
SCAN_REUSE_SECONDS = 20
LIMITS_ONLY_REUSE_SECONDS = 900
def local_day(value):
if value is None:
return datetime.now().strftime("%Y-%m-%d")
if isinstance(value, (int, float)):
# pi message timestamps are milliseconds; Codex timestamps are usually seconds.
if value > 10_000_000_000:
value = value / 1000
return datetime.fromtimestamp(value).strftime("%Y-%m-%d")
text = str(value)
try:
if text.endswith("Z"):
dt = datetime.fromisoformat(text[:-1] + "+00:00")
else:
dt = datetime.fromisoformat(text)
if dt.tzinfo is not None:
dt = dt.astimezone()
return dt.strftime("%Y-%m-%d")
except Exception:
return datetime.now().strftime("%Y-%m-%d")
def number(value):
try:
return int(value or 0)
except Exception:
return 0
def model_name(raw):
value = str(raw or "codex")
return value if value else "codex"
def runtime_env():
home = str(Path.home())
path_parts = [
os.environ.get("PATH", ""),
f"{home}/.local/bin",
f"{home}/.npm-global/bin",
f"{home}/.local/share/mise/shims",
]
env = os.environ.copy()
env["PATH"] = os.pathsep.join(part for part in path_parts if part)
return env
ENV = runtime_env()
def find_command(name):
return shutil.which(name, path=ENV.get("PATH"))
now = datetime.now()
today = now.strftime("%Y-%m-%d")
recent_dates = [(now - timedelta(days=offset)).strftime("%Y-%m-%d") for offset in range(6, -1, -1)]
recent = {day: {"date": day, "messageCount": 0} for day in recent_dates}
today_tokens_by_model = {}
model_usage = {}
today_sessions = set()
active_days = set()
today_prompts = 0
today_total_tokens = 0
total_prompts = 0
total_sessions = set()
seen_pi_messages = set()
def add_usage(day, session_key, model, input_tokens, output_tokens, cache_read, cache_write):
global today_prompts, today_total_tokens, total_prompts
total = input_tokens + output_tokens + cache_read + cache_write
total_prompts += 1
total_sessions.add(session_key)
active_days.add(day)
bucket = model_usage.setdefault(model, {
"inputTokens": 0,
"outputTokens": 0,
"cacheReadInputTokens": 0,
"cacheCreationInputTokens": 0,
})
bucket["inputTokens"] += input_tokens
bucket["outputTokens"] += output_tokens
bucket["cacheReadInputTokens"] += cache_read
bucket["cacheCreationInputTokens"] += cache_write
if day in recent:
recent[day]["messageCount"] += total
if day == today:
today_prompts += 1
today_sessions.add(session_key)
today_total_tokens += total
today_tokens_by_model[model] = today_tokens_by_model.get(model, 0) + total
def scan_pi_sessions():
roots = [
Path.home() / ".pi" / "agent" / "sessions",
Path.home() / ".omp" / "agent" / "sessions",
]
rg = find_command("rg") or "rg"
for root in roots:
if not root.exists():
continue
try:
proc = subprocess.Popen(
[rg, "--json", "-e", r'"provider"\s*:\s*"openai-codex"', "-e", r'"api"\s*:\s*"openai-codex', str(root)],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
errors="replace",
env=ENV,
)
except FileNotFoundError:
return
assert proc.stdout is not None
for raw in proc.stdout:
try:
event = json.loads(raw)
if event.get("type") != "match":
continue
line = event.get("data", {}).get("lines", {}).get("text", "")
path = event.get("data", {}).get("path", {}).get("text", "pi-session")
entry = json.loads(line)
except Exception:
continue
if entry.get("type") != "message":
continue
message_key = path + ":" + str(entry.get("id") or "")
if message_key in seen_pi_messages:
continue
seen_pi_messages.add(message_key)
message = entry.get("message") or {}
if message.get("role") != "assistant":
continue
provider = str(message.get("provider") or "")
api = str(message.get("api") or "")
if provider != "openai-codex" and not api.startswith("openai-codex"):
continue
usage = message.get("usage") or {}
if not usage:
continue
total = number(usage.get("totalTokens"))
input_tokens = number(usage.get("input"))
output_tokens = number(usage.get("output"))
cache_read = number(usage.get("cacheRead"))
cache_write = number(usage.get("cacheWrite"))
if total and not (input_tokens or output_tokens or cache_read or cache_write):
input_tokens = total
if not (input_tokens or output_tokens or cache_read or cache_write):
continue
day = local_day(entry.get("timestamp") or message.get("timestamp"))
session_key = path
add_usage(day, session_key, model_name(message.get("model")), input_tokens, output_tokens, cache_read, cache_write)
try:
proc.wait(timeout=1)
except Exception:
proc.kill()
def scan_opencode_sessions():
# A subscription burned entirely through opencode leaves no native session
# files, but opencode records per-message provider, model, and token usage
# in its own database. Read-only: opencode may be writing right now.
# Returns whether the scan ran to completion: a scan cut short by a
# database error still contributes what it read, but must not be cached
# as if it were the whole story.
db = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share")) / "opencode" / "opencode.db"
if not db.is_file():
return True
try:
conn = sqlite3.connect(db.resolve().as_uri() + "?mode=ro", uri=True, timeout=2)
except sqlite3.Error:
return False
try:
conn.execute("PRAGMA query_only = ON")
# OpenCode DBs grow huge (every historical message, JSON included), and
# Python-side json.loads of every row wasted ~600 MB of RSS on machines
# whose subscription never ran on OpenAI. The json_extract conditions are
# the authority for the rows that reach them: role == "assistant" and
# providerID == "openai", the same exact-match values the Python filter
# below checks. The guards in front are pure acceleration, not a perfect
# proxy for the Python filter:
# - The LIKE gates skip rows whose JSON cannot contain the two
# key/value pairs, avoiding the JSON parse of tens-of-MB blobs. They
# can differ from json.loads on duplicate keys (Python keeps the
# last, SQLite json_extract keeps the first) and ASCII-escaped
# values (\"ass\\u0069stant\" decodes for Python but not for LIKE),
# so a row the authority would accept can be gated out. Both cases
# are vanishingly rare in real opencode data.
# - json_valid guards the parse itself: json_extract() RAISES on
# malformed JSON instead of returning NULL, and one such row would
# otherwise abort the whole scan. SQLite does not promise that AND
# terms evaluate left to right, so the guard is a CASE around each
# json_extract rather than a separate AND term. Rows that are not
# well-formed JSON are skipped here; the per-row try/except below
# stays as the final safety net for rows that pass the SQL filter
# but fail json.loads.
for session_id, raw in conn.execute(
"SELECT session_id, data FROM message"
" WHERE data LIKE '%\"role\"%:%\"assistant\"%'"
" AND data LIKE '%\"providerID\"%:%\"openai\"%'"
" AND CASE WHEN json_valid(data) THEN json_extract(data, '$.role') END = 'assistant'"
" AND CASE WHEN json_valid(data) THEN json_extract(data, '$.providerID') END = 'openai'"
):
# One malformed row must not abort the scan, so every shape assumption
# lives inside the try.
try:
entry = json.loads(raw)
# Exact match: opencode provider ids are free-form, and a custom
# "openai-local" gateway is not this subscription.
if not isinstance(entry, dict) or entry.get("role") != "assistant":
continue
if str(entry.get("providerID") or "") != "openai":
continue
tokens = entry.get("tokens") or {}
cache = tokens.get("cache") or {}
input_tokens = number(tokens.get("input"))
# opencode keeps thinking tokens out of output; both are generated.
output_tokens = number(tokens.get("output")) + number(tokens.get("reasoning"))
cache_read = number(cache.get("read"))
cache_write = number(cache.get("write"))
if not (input_tokens or output_tokens or cache_read or cache_write):
continue
day = local_day((entry.get("time") or {}).get("created"))
model = model_name(str(entry.get("modelID") or "").rstrip("/").split("/")[-1])
except Exception:
continue
add_usage(day, "opencode:" + str(session_id), model, input_tokens, output_tokens, cache_read, cache_write)
except sqlite3.Error:
# Transient lock, schema migration, corruption: the numbers stop here,
# incomplete.
return False
finally:
conn.close()
return True
def scan_native_codex_sessions():
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
roots = [codex_home / "sessions", codex_home / "archived_sessions"]
files = []
cutoff = time.time() - 30 * 24 * 60 * 60
for root in roots:
if not root.exists():
continue
for path in root.rglob("*.jsonl"):
try:
if path.stat().st_mtime >= cutoff:
files.append(path)
except OSError:
pass
for path in files:
current_model = "codex"
try:
with path.open(errors="replace") as handle:
for raw in handle:
try:
entry = json.loads(raw)
except Exception:
continue
if entry.get("type") == "turn_context":
payload = entry.get("payload") or {}
current_model = model_name(payload.get("model") or payload.get("model_slug") or current_model)
continue
payload = entry.get("payload") or entry
if entry.get("type") == "response_item" and isinstance(payload, dict):
payload = payload.get("payload") or payload
if not isinstance(payload, dict):
continue
if payload.get("type") != "token_count":
continue
info = payload.get("info") or {}
# total_token_usage is cumulative for the session. Adding every
# snapshot makes usage grow quadratically, so count the last turn.
usage = info.get("last_token_usage") or {}
cache_read = number(usage.get("cached_input_tokens"))
cache_write = number(usage.get("cache_write_input_tokens"))
# Cached tokens are included in input_tokens, and reasoning tokens
# are included in output_tokens. Keep the cache split without
# counting either category twice.
input_tokens = max(0, number(usage.get("input_tokens")) - cache_read - cache_write)
output_tokens = number(usage.get("output_tokens"))
if not (input_tokens or output_tokens or cache_read or cache_write):
continue
day = local_day(entry.get("timestamp") or path.stat().st_mtime)
add_usage(day, str(path), current_model, input_tokens, output_tokens, cache_read, cache_write)
except Exception:
continue
def cache_root():
root = Path(os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")) / "omarchy" / "agent-usage"
root.mkdir(parents=True, exist_ok=True)
return root
def scan_cache_paths():
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
db = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share")) / "opencode" / "opencode.db"
# The digest covers every data path the scan reads: the codex session
# roots, the opencode DB, and (via Path.home()) the pi/omp session roots.
digest = hashlib.sha1((str(Path.home()) + "\n" + str(codex_home) + "\n" + str(db)).encode("utf-8")).hexdigest()[:16]
root = cache_root()
return root / f"codex-scan-{digest}.json", root / f"codex-scan-{digest}.lock"
def read_fresh_json(path, max_age_seconds):
if max_age_seconds <= 0 or not path.exists():
return None
try:
# A negative age means the mtime is in the future: the clock moved
# backwards since the write, so the cache's freshness cannot be trusted.
age = time.time() - path.stat().st_mtime
if 0 <= age <= max_age_seconds:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
return None
def write_json(path, payload):
# A temp name unique to this writer, not derived from the target: several
# collectors can run at once (the update command backgrounds one per agent,
# the panel refreshes on its own), and a shared temp path means the second
# replace finds the first one's file already moved away.
handle_fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp")
tmp = Path(tmp_name)
try:
with os.fdopen(handle_fd, "w", encoding="utf-8") as handle:
handle.write(json.dumps(payload, separators=(",", ":")) + "\n")
# mkstemp opens at 0600; nothing in the cache is sensitive, so open it
# up to the usual 0644.
tmp.chmod(0o644)
tmp.replace(path)
except BaseException:
tmp.unlink(missing_ok=True)
raise
# The cache payload is a versioned envelope around the local-stats dict, so a
# corrupted or foreign-shaped file is a cache miss (rescan + rewrite) instead
# of a crash or a garbage record.
def read_cached_stats(cache_file, max_age_seconds):
cached = read_fresh_json(cache_file, max_age_seconds)
if not isinstance(cached, dict) or cached.get("schemaVersion") != 1:
return None
# today* fields only mean "today" on the day they were scanned. A cache
# from another local date (midnight passed, or the clock moved) is a miss,
# not merely old, whatever its mtime says.
if cached.get("scanDate") != today:
return None
stats = cached.get("stats")
if not isinstance(stats, dict):
return None
if not all(key in stats for key in ("todayPrompts", "todayTotalTokens", "recentDays", "activeDates", "modelUsage")):
return None
return stats
def write_cached_stats(cache_file, stats):
try:
write_json(cache_file, {"schemaVersion": 1, "scanDate": today, "stats": stats})
except Exception as exc:
print(f"omarchy-agent-usage-codex: could not write usage cache ({exc})", file=sys.stderr)
def local_stats():
"""Snapshot the aggregated local usage into the record's stats dict."""
return {
"todayPrompts": today_prompts,
"todaySessions": len(today_sessions),
"todayTotalTokens": today_total_tokens,
"todayTokensByModel": today_tokens_by_model,
"recentDays": [recent[day] for day in recent_dates],
"totalPrompts": total_prompts,
"totalSessions": len(total_sessions),
# Days with any recorded usage, for the all-time "N days" summary. The
# dates travel too: merging snapshots from several machines needs their
# union, which a count alone cannot give.
"activeDays": len(active_days),
"activeDates": sorted(active_days),
"modelUsage": model_usage,
}
def run_local_scans():
scan_pi_sessions()
scan_native_codex_sessions()
complete = scan_opencode_sessions()
return local_stats(), complete
def cached_local_stats(max_age):
"""Local stats, with the cache as a pure optimization.
The cache must never take the collector down: any cache-layer failure
(unwritable cache root, lock errors, disk full) degrades to a direct scan
and a warning on stderr. The JSON record is the contract; the cache is not.
"""
try:
return _cached_local_stats(max_age)
except Exception as exc:
print(f"omarchy-agent-usage-codex: cache unavailable ({exc}); scanning directly", file=sys.stderr)
stats, _ = run_local_scans()
return stats
def _cached_local_stats(max_age):
cache_file, lock_file = scan_cache_paths()
cached = read_cached_stats(cache_file, max_age)
if cached is not None:
return cached
with lock_file.open("w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
cached = read_cached_stats(cache_file, max_age)
if cached is not None:
return cached
stats, complete = run_local_scans()
# An interrupted scan still serves this run, but caching it would
# suppress the missing usage for every reader until the cache expires.
if complete:
write_cached_stats(cache_file, stats)
return stats
def rpc_request(proc, request_id, method, params=None, timeout=8):
payload = {"id": request_id, "method": method, "params": params or {}}
proc.stdin.write(json.dumps(payload) + "\n")
proc.stdin.flush()
deadline = time.time() + timeout
while time.time() < deadline:
ready, _, _ = select.select([proc.stdout], [], [], 0.25)
if not ready:
continue
line = proc.stdout.readline()
if not line:
break
try:
message = json.loads(line)
except Exception:
continue
if message.get("id") == request_id:
return message
raise TimeoutError(method)
def limit_window(window):
if not isinstance(window, dict):
return None
used = window.get("usedPercent")
if used is None:
return None
mins = number(window.get("windowDurationMins"))
if mins == 10080:
label = "Weekly (7-day)"
elif mins and mins % 60 == 0:
label = f"{mins // 60}h window"
elif mins:
label = f"{mins}m window"
else:
label = "Limit"
reset = window.get("resetsAt")
return {
"label": label,
"percent": float(used) / 100.0,
"resetsAt": datetime.fromtimestamp(number(reset), timezone.utc).isoformat() if reset else "",
}
def fetch_codex_rpc():
result = {"limits": [], "tierLabel": "", "usageStatusText": "", "authHelpText": AUTH_HELP}
codex = find_command("codex")
if not codex:
result["usageStatusText"] = "Codex unavailable"
result["authHelpText"] = "codex not found in PATH"
return result
try:
proc = subprocess.Popen(
[codex, "-s", "read-only", "-a", "on-request", "app-server"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
env=ENV,
)
except Exception as exc:
result["usageStatusText"] = "Codex unavailable"
result["authHelpText"] = str(exc)
return result
try:
rpc_request(proc, 1, "initialize", {"clientInfo": {"name": "omarchy-agent-usage", "version": "1"}}, timeout=8)
proc.stdin.write(json.dumps({"method": "initialized", "params": {}}) + "\n")
proc.stdin.flush()
account_msg = rpc_request(proc, 2, "account/read", timeout=4)
limits_msg = rpc_request(proc, 3, "account/rateLimits/read", timeout=4)
account = (account_msg.get("result") or {}).get("account") or {}
limits = (limits_msg.get("result") or {}).get("rateLimits") or {}
plan = limits.get("planType") or account.get("planType") or account.get("type") or ""
result["tierLabel"] = str(plan) if plan else ""
for window in (limits.get("primary"), limits.get("secondary")):
entry = limit_window(window)
if entry:
result["limits"].append(entry)
except Exception as exc:
result["usageStatusText"] = "Codex limits unavailable"
result["authHelpText"] = str(exc)
finally:
try:
proc.terminate()
proc.wait(timeout=1)
except Exception:
try:
proc.kill()
except Exception:
pass
return result
def main():
parser = argparse.ArgumentParser()
# --force rescans everything and rewrites the cache. --limits-only is kept
# for CLI compatibility with the panel's refreshLimits() call: only the
# limits probe must be fresh, so it may reuse a scan for far longer than a
# normal run, whose short window exists purely to dedup concurrent
# collector runs.
parser.add_argument("--force", action="store_true")
parser.add_argument("--limits-only", action="store_true")
args = parser.parse_args()
max_age = 0 if args.force else (LIMITS_ONLY_REUSE_SECONDS if args.limits_only else SCAN_REUSE_SECONDS)
stats = cached_local_stats(max_age)
rpc = fetch_codex_rpc()
record = {
"schemaVersion": 1,
"id": AGENT_ID,
"name": AGENT_NAME,
"updatedAt": datetime.now(timezone.utc).isoformat(),
"ready": True,
"hasLocalStats": True,
}
record.update(stats)
record.update(rpc)
print(json.dumps(record, separators=(",", ":")))
if __name__ == "__main__":
main()
-511
View File
@@ -1,511 +0,0 @@
#!/usr/bin/python3
# omarchy:summary=Print the Fireworks usage record as JSON
# omarchy:args=[--force] [--limits-only]
# omarchy:hidden=true
"""Collect Fireworks serverless usage into one display-ready JSON record.
Token stats come from the Fireworks billing API grouped by day and model for
the last 30 days. Fireworks does not expose its prepaid ledger, so the record
carries an estimated balance instead of rate limits: the credits configured in
~/.config/omarchy/agents/fireworks.json minus rated account costs since the
funding date. The agents panel only ever reads the JSON this prints.
"""
from __future__ import annotations
import argparse
import configparser
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import date, datetime, time, timedelta, timezone
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Any
AGENT_ID = "fireworks"
AGENT_NAME = "Fireworks"
AUTH_HELP = "Set FIREWORKS_API_KEY, run `firectl set-api-key`, or sign in to Fireworks in opencode."
API_BASE_URL = "https://api.fireworks.ai"
class FireworksError(Exception):
pass
def number(value: Any) -> int:
try:
return max(0, round(float(value or 0)))
except (TypeError, ValueError):
return 0
def money_value(value: Any) -> Decimal:
if not isinstance(value, dict):
return Decimal("0")
try:
units = Decimal(str(value.get("units", 0) or 0))
nanos = Decimal(str(value.get("nanos", 0) or 0)) / Decimal("1000000000")
return units + nanos
except (InvalidOperation, TypeError, ValueError):
return Decimal("0")
def model_id(row: dict[str, Any]) -> str:
group = row.get("group") if isinstance(row.get("group"), dict) else {}
raw = group.get("model_name") or row.get("modelName") or "unknown"
name = str(raw).rstrip("/").split("/")[-1] or "unknown"
return re.sub(r"(?<=\d)p(?=\d)", ".", name)
def row_date(row: dict[str, Any]) -> str:
# The query asks for day buckets in the local timezone, but the API reports
# each bucket's boundary in UTC: local Aug 7 starts at Aug 6 22:00Z east of
# Greenwich. Convert back to local time to recover the day the bucket names —
# taking the raw date prefix would file every day under its predecessor.
raw = str(row.get("startTime") or "")
if not raw:
return ""
try:
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return raw[:10] if len(raw) >= 10 else ""
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone().date().isoformat()
def empty_bucket() -> dict[str, int]:
return {
"inputTokens": 0,
"outputTokens": 0,
"cacheReadInputTokens": 0,
"cacheCreationInputTokens": 0,
}
def empty_stats() -> dict[str, Any]:
return {
"todayPrompts": 0,
"todaySessions": 0,
"todayTotalTokens": 0,
"todayTokensByModel": {},
"recentDays": [],
"totalPrompts": 0,
"totalSessions": 0,
"activeDays": 0,
"activeDates": [],
"modelUsage": {},
}
def base_record(**overrides: Any) -> dict[str, Any]:
record: dict[str, Any] = {
"schemaVersion": 1,
"id": AGENT_ID,
"name": AGENT_NAME,
"updatedAt": datetime.now(timezone.utc).isoformat(),
"ready": False,
"hasLocalStats": False,
# Billing-API numbers are account-global, not machine-local: every synced
# device reports the same truth, so aggregation must not sum them.
"scope": "account",
# The billing API reports tokens, never prompt or session counts; the
# panel keeps those numbers out of today's tooltip when this is false.
"hasPromptStats": False,
"tierLabel": "Prepaid",
"usageStatusText": "",
"authHelpText": "",
"limits": [],
}
record.update(empty_stats())
record.update(overrides)
return record
def summarize_usage(payload: dict[str, Any], today: date | None = None) -> dict[str, Any]:
today = today or datetime.now().astimezone().date()
recent_dates = [(today - timedelta(days=offset)).isoformat() for offset in range(6, -1, -1)]
recent = {day: 0 for day in recent_dates}
today_by_model: dict[str, int] = {}
model_usage: dict[str, dict[str, int]] = {}
active_dates: set[str] = set()
rows = payload.get("serverlessCosts")
if not isinstance(rows, list):
rows = []
for raw_row in rows:
if not isinstance(raw_row, dict):
continue
day = row_date(raw_row)
model = model_id(raw_row)
prompt = number(raw_row.get("promptTokens"))
cached = min(prompt, number(raw_row.get("cachedPromptTokens")))
uncached = number(raw_row.get("uncachedPromptTokens"))
if "uncachedPromptTokens" not in raw_row:
uncached = max(0, prompt - cached)
output = number(raw_row.get("completionTokens"))
total = uncached + cached + output
if total <= 0:
continue
bucket = model_usage.setdefault(model, empty_bucket())
bucket["inputTokens"] += uncached
bucket["outputTokens"] += output
bucket["cacheReadInputTokens"] += cached
if day:
active_dates.add(day)
if day in recent:
recent[day] += total
if day == today.isoformat():
today_by_model[model] = today_by_model.get(model, 0) + total
return {
"todayTotalTokens": sum(today_by_model.values()),
"todayTokensByModel": today_by_model,
"recentDays": [{"date": day, "messageCount": recent[day]} for day in recent_dates],
"activeDays": len(active_dates),
"activeDates": sorted(active_dates),
"modelUsage": model_usage,
}
def read_auth_file(path: Path) -> tuple[str, str]:
if not path.is_file():
return "", ""
parser = configparser.ConfigParser(interpolation=None)
try:
parser.read(path)
except configparser.Error:
return "", ""
api_key = ""
account_id = ""
sections = [parser.defaults()]
sections.extend(parser[section] for section in parser.sections())
for values in sections:
api_key = api_key or str(values.get("api_key", values.get("api-key", ""))).strip()
account_id = account_id or str(values.get("account_id", values.get("account-id", ""))).strip()
return api_key, account_id
def opencode_auth_path() -> Path:
data_home = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share"))
return data_home / "opencode" / "auth.json"
def read_opencode_key(path: Path) -> str:
try:
parsed = json.loads(path.read_text())
except (OSError, json.JSONDecodeError):
return ""
entry = parsed.get("fireworks-ai") if isinstance(parsed, dict) else None
if not isinstance(entry, dict):
return ""
return str(entry.get("key") or "").strip()
def config_path() -> Path:
config_home = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
return config_home / "omarchy" / "agents" / "fireworks.json"
def read_config() -> dict[str, Any]:
try:
parsed = json.loads(config_path().read_text())
return parsed if isinstance(parsed, dict) else {}
except (OSError, json.JSONDecodeError):
return {}
def credentials(auth_path: Path, config: dict[str, Any]) -> tuple[str, str]:
file_key, file_account = read_auth_file(auth_path)
# opencode is the last resort: an explicit key or a firectl login should
# win over whatever another tool happens to be signed in with.
api_key = (
str(os.environ.get("FIREWORKS_API_KEY", "")).strip()
or file_key
or read_opencode_key(opencode_auth_path())
)
account_id = (
str(os.environ.get("FIREWORKS_ACCOUNT_ID", "")).strip()
or str(config.get("accountId") or "").strip()
or file_account
)
return api_key, account_id
def normalize_account_id(value: str) -> str:
return str(value or "").strip().removeprefix("accounts/").strip("/")
def timezone_name() -> str:
configured = str(os.environ.get("TZ", "")).strip()
if configured:
return configured
try:
target = (Path("/etc/localtime").resolve()).as_posix()
marker = "/zoneinfo/"
if marker in target:
return target.split(marker, 1)[1]
except OSError:
pass
return "UTC"
def local_midnight_utc(day: date) -> str:
# The API buckets by the requested timezone, so the window must run between
# local midnights — expressed in UTC, since a bare date with a Z suffix
# shifts the window by the UTC offset and clips today's tail west of
# Greenwich.
return datetime.combine(day, time.min).astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
def iso_timestamp(value: str) -> str:
raw = str(value or "").strip()
if not raw:
return ""
try:
if len(raw) == 10:
parsed = datetime.combine(date.fromisoformat(raw), time.min, tzinfo=timezone.utc)
else:
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
except ValueError:
raise FireworksError("Fireworks fundedAt must be an ISO date such as 2026-07-01")
class FireworksClient:
def __init__(self, api_key: str, base_url: str = API_BASE_URL):
self.api_key = api_key
self.base_url = base_url.rstrip("/")
def request(
self,
path: str,
query: dict[str, Any] | None = None,
body: dict[str, Any] | None = None,
) -> dict[str, Any]:
url = self.base_url + path
if query:
url += "?" + urllib.parse.urlencode(query, doseq=True)
data = None if body is None else json.dumps(body).encode("utf-8")
request = urllib.request.Request(
url,
data=data,
method="POST" if body is not None else "GET",
headers={
"Authorization": "Bearer " + self.api_key,
"Accept": "application/json",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
decoded = json.load(response)
return decoded if isinstance(decoded, dict) else {}
except urllib.error.HTTPError as error:
if error.code == 401:
raise FireworksError("Fireworks rejected the API key")
if error.code == 403:
raise FireworksError("The Fireworks API key cannot read billing data")
if error.code == 404:
raise FireworksError("Fireworks account not found")
raise FireworksError(f"Fireworks API returned HTTP {error.code}")
except urllib.error.URLError as error:
raise FireworksError("Could not reach the Fireworks API") from error
except (json.JSONDecodeError, TimeoutError) as error:
raise FireworksError("Fireworks returned an invalid billing response") from error
def discover_account(self) -> tuple[str, dict[str, Any]]:
payload = self.request("/v1/accounts", query={"pageSize": 100})
accounts = [item for item in payload.get("accounts", []) if isinstance(item, dict)]
if len(accounts) == 1:
account = accounts[0]
return normalize_account_id(str(account.get("name") or "")), account
if not accounts:
raise FireworksError("No Fireworks account is available for this API key")
raise FireworksError("Set accountId in fireworks.json when the API key can access multiple accounts")
def account(self, account_id: str) -> dict[str, Any]:
quoted = urllib.parse.quote(normalize_account_id(account_id), safe="")
return self.request(f"/v1/accounts/{quoted}")
def usage(self, account_id: str, start_day: date, end_day: date) -> dict[str, Any]:
quoted = urllib.parse.quote(normalize_account_id(account_id), safe="")
query = {
"startTime": local_midnight_utc(start_day),
"endTime": local_midnight_utc(end_day),
"usageType": "SERVERLESS",
"timezone": timezone_name(),
"groupBy": ["model_name"],
}
# 30 days grouped by model can exceed one page; follow the continuation
# tokens or heavy accounts lose their tail. The bound is a runaway stop.
rows: list[Any] = []
for _ in range(20):
payload = self.request(f"/v1/accounts/{quoted}/billingUsage", query=query)
page = payload.get("serverlessCosts")
if isinstance(page, list):
rows.extend(page)
token = str(payload.get("nextPageToken") or "")
if not token:
break
query = dict(query, pageToken=token)
return {"serverlessCosts": rows}
def spent(self, account_id: str, start_at: str, end_at: str) -> Decimal:
quoted = urllib.parse.quote(normalize_account_id(account_id), safe="")
body = {
"startTime": start_at,
"endTime": end_at,
"scope": "ACCOUNT",
}
try:
payload = self.request(f"/v1/accounts/{quoted}/usageCosts:query", body=body)
if not isinstance(payload.get("subtotal"), dict):
raise FireworksError("Fireworks cost response did not include a subtotal")
return money_value(payload.get("subtotal"))
except FireworksError:
parsed_end = datetime.fromisoformat(end_at.replace("Z", "+00:00"))
summary_end = (parsed_end.date() + timedelta(days=1)).isoformat() + "T00:00:00Z"
payload = self.request(
f"/v1/accounts/{quoted}/billing/summary",
query={"startTime": start_at, "endTime": summary_end},
)
return sum(
(money_value(item.get("totalCost")) for item in payload.get("lineItems", []) if isinstance(item, dict)),
Decimal("0"),
)
def live_balance(client: FireworksClient, account_id: str) -> Decimal | None:
# accounts/{id}:getBalance exists but is permission-gated: keys without the
# billing role get PERMISSION_DENIED, and then the configured estimate below
# is the best we can do. The response shape is undocumented, so accept a
# Money object at the top level or under any plausible field name.
quoted = urllib.parse.quote(normalize_account_id(account_id), safe="")
try:
payload = client.request(f"/v1/accounts/{quoted}:getBalance")
except FireworksError:
return None
candidates = [payload] + [payload.get(field) for field in ("balance", "creditBalance", "prepaidBalance", "amount")]
for value in candidates:
if isinstance(value, dict) and ("units" in value or "nanos" in value):
return money_value(value)
return None
def estimated_balance(
client: FireworksClient,
account_id: str,
account: dict[str, Any],
config: dict[str, Any],
) -> dict[str, Any] | None:
try:
funded = Decimal(str(config.get("fundedAmount") or "0"))
except InvalidOperation:
raise FireworksError("Fireworks fundedAmount must be a number")
if not funded.is_finite():
raise FireworksError("Fireworks fundedAmount must be a finite number")
if funded <= 0:
return None
funded_at = iso_timestamp(str(config.get("fundedAt") or ""))
if not funded_at:
if not account:
account = client.account(account_id)
funded_at = iso_timestamp(str(account.get("createTime") or ""))
if not funded_at:
raise FireworksError("Set fundedAt because the Fireworks account creation date is unavailable")
end_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
spent = max(Decimal("0"), client.spent(account_id, funded_at, end_at))
return {
"remaining": float(max(Decimal("0"), funded - spent)),
"funded": float(funded),
"spent": float(spent),
"currency": "USD",
"estimated": True,
}
def scan(api_base_url: str, auth_path: Path) -> dict[str, Any]:
config = read_config()
api_key, account_id = credentials(auth_path, config)
if not api_key:
return base_record(usageStatusText="Fireworks unavailable", authHelpText=AUTH_HELP)
client = FireworksClient(api_key, api_base_url)
account: dict[str, Any] = {}
if account_id:
account_id = normalize_account_id(account_id)
else:
account_id, account = client.discover_account()
today = datetime.now().astimezone().date()
usage = client.usage(account_id, today - timedelta(days=29), today + timedelta(days=1))
record = base_record(ready=True, hasLocalStats=True)
record.update(summarize_usage(usage, today))
live = live_balance(client, account_id)
if live is not None:
try:
funded = Decimal(str(config.get("fundedAmount") or "0"))
if not funded.is_finite() or funded < 0:
funded = Decimal("0")
except InvalidOperation:
funded = Decimal("0")
record["balance"] = {
"remaining": float(live),
"funded": float(funded),
"spent": float(max(Decimal("0"), funded - live)),
"currency": "USD",
"estimated": False,
}
return record
try:
balance = estimated_balance(client, account_id, account, config)
if balance:
record["balance"] = balance
except FireworksError as error:
record["usageStatusText"] = "Balance unavailable"
record["authHelpText"] = str(error)
return record
def main() -> int:
parser = argparse.ArgumentParser(description="Print the Fireworks usage record as JSON")
# Stats and balance come from the same few API calls, so there is no cache
# to force past and no faster limits-only path. The flags exist so every
# collector accepts the same invocation.
parser.add_argument("--force", action="store_true")
parser.add_argument("--limits-only", action="store_true")
parser.add_argument("--auth-path", default=os.environ.get("FIREWORKS_AUTH_PATH", "~/.fireworks/auth.ini"))
parser.add_argument("--api-base-url", default=os.environ.get("FIREWORKS_API_BASE_URL", API_BASE_URL))
args = parser.parse_args()
try:
record = scan(args.api_base_url, Path(args.auth_path).expanduser())
except FireworksError as error:
record = base_record(usageStatusText="Fireworks unavailable", authHelpText=str(error))
except Exception as error:
record = base_record(usageStatusText="Fireworks unavailable", authHelpText="Fireworks usage scan failed")
print(f"omarchy-agent-usage-fireworks: {type(error).__name__}", file=sys.stderr)
print(json.dumps(record, separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main())
-68
View File
@@ -1,68 +0,0 @@
#!/bin/bash
# omarchy:summary=Regenerate the AI agent usage data files
# omarchy:args=[--force] [--limits-only] [--except <agent>] [agent...]
# omarchy:examples=omarchy agent usage-update | omarchy agent usage-update claude | omarchy agent usage-update --except codex
# Each omarchy-agent-usage-<agent> collector prints one display-ready JSON
# record; this writes them to ~/.local/state/omarchy/agents/usage/ where the
# agents panel watches them. Adding an agent is adding a collector — the
# panel picks up any record that appears here.
USAGE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/agents/usage"
mkdir -p "$USAGE_DIR"
flags=()
only=()
declare -A excluded
while [[ $# -gt 0 ]]; do
case "$1" in
--force | --limits-only) flags+=("$1") ;;
--except)
excluded[$2]=1
shift
;;
*) only+=("$1") ;;
esac
shift
done
wanted() {
local agent="$1"
[[ -n ${excluded[$agent]} ]] && return 1
(( ${#only[@]} == 0 )) && return 0
local candidate
for candidate in "${only[@]}"; do
[[ $candidate == "$agent" ]] && return 0
done
return 1
}
collect() {
local collector="$1" agent="$2"
local record tmp
if ! record=$("$collector" "${flags[@]}") || [[ -z $record ]] || ! jq -e . >/dev/null 2>&1 <<<"$record"; then
echo "omarchy-agent-usage-update: $agent collector failed" >&2
return 1
fi
tmp=$(mktemp "$USAGE_DIR/.$agent.XXXXXX")
printf '%s\n' "$record" >"$tmp"
mv "$tmp" "$USAGE_DIR/$agent.json"
}
pids=()
for collector in "$OMARCHY_PATH"/bin/omarchy-agent-usage-*; do
[[ -x $collector ]] || continue
agent="${collector##*/omarchy-agent-usage-}"
[[ $agent == "update" ]] && continue
wanted "$agent" || continue
collect "$collector" "$agent" &
pids+=($!)
done
status=0
for pid in "${pids[@]}"; do
wait "$pid" || status=1
done
exit $status
-75
View File
@@ -1,75 +0,0 @@
#!/bin/bash
# omarchy:summary=Apply Omarchy hardware-specific packages and system configuration
# omarchy:group=apply
# omarchy:requires-sudo=true
# omarchy:examples=omarchy apply hardware --install-user dhh
# omarchy:hidden=true
set -euo pipefail
usage() {
cat <<USAGE
Usage: omarchy apply hardware --install-user USER
omarchy apply hardware --defer-provisioning
Runs idempotent hardware detection/setup for the installed machine. This is
called by omarchy-apply-system during ISO finalization and can be rerun later
for diagnostics or after hardware/package updates.
--defer-provisioning runs without an install user (deferred-provisioning installs create the user at
first boot); group grants are recorded in /var/lib/omarchy/provisioning/groups.
USAGE
}
install_user="${OMARCHY_INSTALL_USER:-}"
defer_provisioning=0
while (($#)); do
case "$1" in
--install-user)
install_user="${2:-}"
shift 2
;;
--defer-provisioning)
defer_provisioning=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
if (( EUID != 0 )); then
echo "Error: omarchy-apply-hardware must run as root" >&2
exit 1
fi
if (( defer_provisioning )); then
install_user=""
else
if [[ -z $install_user || $install_user == "root" ]]; then
echo "Error: --install-user must name the target non-root user" >&2
exit 1
fi
if ! getent passwd "$install_user" >/dev/null; then
echo "Error: user '$install_user' does not exist" >&2
exit 1
fi
fi
export OMARCHY_INSTALL_USER="$install_user"
export OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}"
export OMARCHY_INSTALL="${OMARCHY_INSTALL:-$OMARCHY_PATH/install}"
export OMARCHY_INSTALL_LOG_FILE="${OMARCHY_INSTALL_LOG_FILE:-/var/log/omarchy-install.log}"
export PATH="$OMARCHY_PATH/bin:$PATH"
source "$OMARCHY_INSTALL/helpers/logging.sh"
source "$OMARCHY_INSTALL/hardware/all.sh"
-60
View File
@@ -1,60 +0,0 @@
#!/bin/bash
# omarchy:summary=Configure Quickshell lock screen authentication
# omarchy:requires-sudo=true
# omarchy:hidden=true
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)
fi
target_user=${target_user:-$USER}
as_root() {
if (( EUID == 0 )); then
"$@"
else
sudo "$@"
fi
}
echo "Configuring lock screen password authentication..."
as_root tee /etc/pam.d/omarchy-lock-password >/dev/null <<'EOF'
#%PAM-1.0
auth required pam_faillock.so preauth silent deny=10 unlock_time=120
-auth [success=2 default=ignore] pam_systemd_home.so
auth [success=1 default=bad] pam_unix.so try_first_pass nullok
auth [default=die] pam_faillock.so authfail deny=10 unlock_time=120
auth optional pam_permit.so
auth required pam_env.so
auth required pam_faillock.so authsucc
account include system-local-login
EOF
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
auth required pam_fprintd.so
account include system-local-login
EOF
else
as_root rm -f /etc/pam.d/omarchy-lock-fingerprint
fi
# omarchy-shell can't reach a running shell during chroot install. The echo
# is just confirmation, so swallow the failure rather than letting it become
# the script's exit code.
if omarchy-shell lock status >/dev/null 2>&1; then
echo "Lock screen authentication configured."
fi
-102
View File
@@ -1,102 +0,0 @@
#!/bin/bash
# omarchy:summary=Apply Omarchy system setup in the installed target
# omarchy:group=apply
# omarchy:requires-sudo=true
# omarchy:examples=omarchy apply system --install-user dhh --first-install
# omarchy:hidden=true
set -euo pipefail
usage() {
cat <<USAGE
Usage: omarchy apply system --install-user USER [--first-install|--upgrade]
omarchy apply system --defer-provisioning --first-install
Runs root-owned Omarchy system setup. The ISO calls this in the target chroot
after packages are installed and the target user exists. It also calls
omarchy-apply-hardware, so hardware setup cannot be accidentally skipped.
--defer-provisioning runs without an install user: deferred-provisioning installs create the user at
first boot, so scripts that grant group memberships record them in
/var/lib/omarchy/provisioning/groups instead of calling usermod.
USAGE
}
install_user="${OMARCHY_INSTALL_USER:-}"
first_install=0
upgrade=0
defer_provisioning=0
while (($#)); do
case "$1" in
--install-user)
install_user="${2:-}"
shift 2
;;
--defer-provisioning)
defer_provisioning=1
shift
;;
--first-install)
first_install=1
shift
;;
--upgrade)
upgrade=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
if (( EUID != 0 )); then
echo "Error: omarchy-apply-system must run as root" >&2
exit 1
fi
if (( defer_provisioning )); then
install_user=""
else
if [[ -z $install_user || $install_user == "root" ]]; then
echo "Error: --install-user must name the target non-root user" >&2
exit 1
fi
if ! getent passwd "$install_user" >/dev/null; then
echo "Error: user '$install_user' does not exist" >&2
exit 1
fi
fi
export OMARCHY_INSTALL_USER="$install_user"
export OMARCHY_FIRST_INSTALL="$first_install"
export OMARCHY_UPGRADE="$upgrade"
export OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}"
export OMARCHY_INSTALL="${OMARCHY_INSTALL:-$OMARCHY_PATH/install}"
export OMARCHY_INSTALL_LOG_FILE="${OMARCHY_INSTALL_LOG_FILE:-/var/log/omarchy-install.log}"
export PATH="$OMARCHY_PATH/bin:$PATH"
source "$OMARCHY_INSTALL/helpers/logging.sh"
start_install_log
source "$OMARCHY_INSTALL/config/all.sh"
if (( defer_provisioning )); then
omarchy-apply-hardware --defer-provisioning
else
omarchy-apply-hardware --install-user "$install_user"
fi
source "$OMARCHY_INSTALL/login/all.sh"
source "$OMARCHY_INSTALL/post-install/all.sh"
stop_install_log
+13 -5
View File
@@ -4,10 +4,18 @@
wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle >/dev/null wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle >/dev/null
if wpctl get-volume @DEFAULT_AUDIO_SOURCE@ | grep -q MUTED; then if pactl get-source-mute @DEFAULT_SOURCE@ | rg -q 'yes'; then
omarchy-brightness-keyboard-mute on led=on
omarchy-osd -i microphone-muted -m "Microphone muted" osd_message='Microphone muted'
osd_icon='microphone-sensitivity-muted-symbolic'
else else
omarchy-brightness-keyboard-mute off led=off
omarchy-osd -i microphone -m "Microphone on" osd_message='Microphone on'
osd_icon='audio-input-microphone-symbolic'
fi fi
omarchy-brightness-keyboard-mute "$led"
omarchy-swayosd-client \
--custom-message "$osd_message" \
--custom-icon "$osd_icon"
-20
View File
@@ -1,20 +0,0 @@
#!/bin/bash
# omarchy:summary=Set the default audio input and move active streams
# omarchy:args=<node-id> <source-name>
# omarchy:examples=omarchy audio input set default 43 alsa_input.pci-0000_00_1f.3.analog-stereo
node_id=${1:-}
source_name=${2:-}
if [[ -z $node_id || -z $source_name ]]; then
echo "Usage: omarchy-audio-input-set-default <node-id> <source-name>" >&2
exit 1
fi
wpctl set-default "$node_id" 2>/dev/null || true
pactl set-default-source "$source_name" 2>/dev/null || true
pactl list short source-outputs 2>/dev/null | awk '{ print $1 }' | while read -r output; do
[[ -n $output ]] && pactl move-source-output "$output" "$source_name" 2>/dev/null || true
done
-31
View File
@@ -1,31 +0,0 @@
#!/bin/bash
# omarchy:summary=Set the default audio output and move active streams
# omarchy:args=<node-id> <sink-name>
# omarchy:examples=omarchy audio output set default 42 alsa_output.pci-0000_00_1f.3.analog-stereo
node_id=${1:-}
sink_name=${2:-}
if [[ -z $node_id || -z $sink_name ]]; then
echo "Usage: omarchy-audio-output-set-default <node-id> <sink-name>" >&2
exit 1
fi
timeout 2 wpctl set-default "$node_id" 2>/dev/null || true
timeout 2 pactl set-default-sink "$sink_name" 2>/dev/null || true
# Move only real application streams. A DSP filter-chain's own output is also a
# sink input but carries no application.name, and moving it would rewire the
# processing itself -- onto headphones, or into its own virtual sink, which is a
# cycle. EasyEffects' output stream must stay put for the same reason.
timeout 2 pactl list sink-inputs 2>/dev/null | awk '
/^Sink Input #/ {id = substr($3, 2)}
/application\.name = / {
app = $0
sub(/.*application\.name = "/, "", app)
sub(/"$/, "", app)
if (app != "EasyEffects") print id
}' | while read -r input; do
[[ -n $input ]] && timeout 2 pactl move-sink-input "$input" "$sink_name" 2>/dev/null || true
done
-55
View File
@@ -1,55 +0,0 @@
#!/bin/bash
# omarchy:summary=Print the sink whose volume and mute a given output really uses
# omarchy:args=[sink-name]
# omarchy:group=audio
# omarchy:examples=omarchy audio output sink | omarchy audio output sink omarchy_speaker_tuning
set -uo pipefail
# A DSP sink -- a speaker tuning filter-chain, or EasyEffects -- can be the
# selected output without being where loudness lives. Changing its volume alters
# the level going *into* the processing: the display moves while the speakers do
# not, and on a chain with a compressor or limiter the tone changes too. Resolve
# through it to the physical sink it feeds.
#
# With no argument this resolves the current default output, so when headphones or
# HDMI are selected it returns those, not the speakers a tuning happens to front.
# Callers that need to describe some *other* output -- an output switcher naming
# the next one in the rotation -- pass that sink explicitly.
sink="${1:-$(pactl get-default-sink 2>/dev/null)}"
if [[ -z $sink || $sink == alsa_output.* ]]; then
printf '%s\n' "$sink"
exit 0
fi
# A DSP sink feeds its physical output through a stream of its own; follow that
# stream down to the sink underneath.
downstream="$(pactl list sink-inputs 2>/dev/null |
awk -v virt="$sink" '
/^Sink Input #/ {target = ""}
/^[[:space:]]*Sink:/ {target = $2}
/node\.name = / {
name = $0
sub(/.*node\.name = "/, "", name)
sub(/"$/, "", name)
if (index(name, virt) == 1 && target != "") {print target; exit}
}
/application\.name = "EasyEffects"/ {
if (virt == "easyeffects_sink" && target != "") {print target; exit}
}')"
if [[ -n $downstream ]]; then
name="$(pactl list sinks short 2>/dev/null |
awk -v id="$downstream" '$1 == id {print $2; exit}')"
if [[ -n $name ]]; then
printf '%s\n' "$name"
exit 0
fi
fi
# Nothing resolvable downstream -- the DSP sink may simply be idle and unlinked.
# Fall back to the sink itself so callers still have something to act on.
printf '%s\n' "$sink"
+33 -30
View File
@@ -1,24 +1,17 @@
#!/bin/bash #!/bin/bash
# omarchy:summary=Switch between audio outputs while preserving the mute status # omarchy:summary=Switch between audio outputs while preserving the mute status. By default mapped to Super + Mute.
# Skip the physical sink an active speaker tuning fronts: rotating onto it would sinks=$(pactl -f json list sinks | jq '[.[] | select((.ports | length == 0) or ([.ports[]? | .availability != "not available"] | any))]')
# silently bypass the tuning rather than pick a different output. sinks_count=$(echo "$sinks" | jq '. | length')
fronted=$(omarchy-audio-tuning fronted-sink 2>/dev/null || true)
sinks=$(timeout 2 pactl -f json list sinks |
jq --arg fronted "$fronted" '[.[]
| select((.ports | length == 0) or ([.ports[]? | .availability != "not available"] | any))
| select($fronted == "" or .name != $fronted)]')
sinks_count=$(jq 'length' <<<"$sinks")
if (( sinks_count == 0 )); then if (( sinks_count == 0 )); then
omarchy-osd -m "No audio devices found" omarchy-swayosd-client --custom-message "No audio devices found"
exit 1 exit 1
fi fi
current_sink_name=$(timeout 2 pactl get-default-sink) current_sink_name=$(pactl get-default-sink)
current_sink_index=$(jq -r --arg name "$current_sink_name" 'map(.name) | index($name)' <<<"$sinks") current_sink_index=$(echo "$sinks" | jq -r --arg name "$current_sink_name" 'map(.name) | index($name)')
if [[ $current_sink_index != "null" ]]; then if [[ $current_sink_index != "null" ]]; then
next_sink_index=$(((current_sink_index + 1) % sinks_count)) next_sink_index=$(((current_sink_index + 1) % sinks_count))
@@ -26,23 +19,28 @@ else
next_sink_index=0 next_sink_index=0
fi fi
next_sink=$(jq -c ".[$next_sink_index]" <<<"$sinks") next_sink=$(echo "$sinks" | jq -r ".[$next_sink_index]")
next_sink_name=$(jq -r '.name' <<<"$next_sink") next_sink_name=$(echo "$next_sink" | jq -r '.name')
next_sink_description=$(jq -r '.description // .properties."device.description" // .name' <<<"$next_sink")
# A tuning sink sits at a fixed 100% and unmuted while real loudness lives on the next_sink_description=$(echo "$next_sink" | jq -r '.description')
# physical sink beneath it, so read the level from whichever sink actually carries if [[ $next_sink_description == "(null)" ]] || [[ $next_sink_description == "null" ]] || [[ -z $next_sink_description ]]; then
# it or the OSD contradicts the volume keys. # For Bluetooth devices, the friendly name is on the Device entry (device.id), not the Sink entry (object.id)
next_sink_effective=$(omarchy-audio-output-sink "$next_sink_name") device_id=$(echo "$next_sink" | jq -r '.properties."device.id"')
next_sink_volume=$(timeout 2 pactl get-sink-volume "$next_sink_effective" 2>/dev/null | if [[ $device_id != "null" ]] && [[ -n $device_id ]]; then
awk 'NR == 1 {for (i = 1; i <= NF; i++) if ($i ~ /%$/) {sub("%", "", $i); print $i; exit}}') next_sink_description=$(wpctl status | grep -E "^\s*│?\s+${device_id}\." | sed -E 's/^.*[0-9]+\.\s+//' | sed -E 's/\s+\[.*$//')
[[ -n $next_sink_volume ]] || next_sink_volume=$(jq -r '.volume | to_entries[0].value.value_percent | sub("%"; "") | tonumber' <<<"$next_sink") fi
if [[ $(timeout 2 pactl get-sink-mute "$next_sink_effective" 2>/dev/null) == *yes ]]; then # Fall back to object.id lookup if device.id didn't yield a result
next_sink_is_muted=true if [[ -z $next_sink_description ]]; then
else sink_id=$(echo "$next_sink" | jq -r '.properties."object.id"')
next_sink_is_muted=false next_sink_description=$(wpctl status | grep -E "\s+\*?\s+${sink_id}\." | sed -E 's/^.*[0-9]+\.\s+//' | sed -E 's/\s+\[.*$//')
fi
fi fi
if [[ $next_sink_is_muted == "true" ]] || (( next_sink_volume == 0 )); then next_sink_volume=$(echo "$next_sink" | jq -r \
'.volume | to_entries[0].value.value_percent | sub("%"; "")')
next_sink_is_muted=$(echo "$next_sink" | jq -r '.mute')
if [[ $next_sink_is_muted = "true" ]] || (( next_sink_volume == 0 )); then
icon_state="muted" icon_state="muted"
elif (( next_sink_volume <= 33 )); then elif (( next_sink_volume <= 33 )); then
icon_state="low" icon_state="low"
@@ -52,8 +50,13 @@ else
icon_state="high" icon_state="high"
fi fi
next_sink_volume_icon="sink-volume-${icon_state}-symbolic"
if [[ $next_sink_name != $current_sink_name ]]; then if [[ $next_sink_name != $current_sink_name ]]; then
omarchy-audio-output-set-default "$(jq -r '.index' <<<"$next_sink")" "$next_sink_name" next_sink_wpid=$(echo "$next_sink" | jq -r '.properties."object.id"')
wpctl set-default "$next_sink_wpid"
fi fi
omarchy-osd -i "volume-${icon_state}" -m "$next_sink_description" omarchy-swayosd-client \
--custom-message "$next_sink_description" \
--custom-icon "$next_sink_volume_icon"
-86
View File
@@ -1,86 +0,0 @@
#!/bin/bash
# omarchy:summary=Adjust output volume and show the Omarchy OSD
# omarchy:args=<raise|lower|mute-toggle|+N|-N>
# omarchy:examples=omarchy audio output volume raise | omarchy audio output volume lower | omarchy audio output volume mute-toggle | omarchy audio output volume +1
action="${1:-}"
if [[ -z $action ]]; then
echo "Usage: omarchy-audio-output-volume <raise|lower|mute-toggle|+N|-N>"
exit 1
fi
# Resolve through any DSP sink to the physical one, so the keys always move real
# loudness and the processing always sees full-scale input. Shared with the audio
# panel and the output switcher.
sink="$(omarchy-audio-output-sink)"
if [[ -z $sink ]]; then
echo "Could not resolve an audio sink to control." >&2
exit 1
fi
# pactl reports the same percentage scale wpctl does (both are the raw volume
# over PA_VOLUME_NORM), so the OSD reads identically either way.
volume_percent() {
pactl get-sink-volume "$sink" 2>/dev/null |
awk 'NR == 1 {
for (i = 1; i <= NF; i++)
if ($i ~ /%$/) {sub("%", "", $i); print $i; exit}
}'
}
volume_muted() {
[[ $(pactl get-sink-mute "$sink" 2>/dev/null) == *yes ]]
}
case "$action" in
raise) action="+5" ;;
lower) action="-5" ;;
esac
if [[ $action == "mute-toggle" ]]; then
runtime_dir="${XDG_RUNTIME_DIR:-/tmp}"
debounce_file="$runtime_dir/omarchy-audio-output-volume-mute-toggle.last"
now=$(date +%s%3N)
last=0
[[ -r $debounce_file ]] && read -r last <"$debounce_file" || true
if ((now - last < 250)); then
exit 0
fi
printf '%s\n' "$now" >"$debounce_file"
pactl set-sink-mute "$sink" toggle
elif [[ $action =~ ^([+-])([0-9]+)$ ]]; then
direction="${BASH_REMATCH[1]}"
step="${BASH_REMATCH[2]}"
current="$(volume_percent)"
if [[ -z $current ]]; then
echo "Could not read volume for $sink." >&2
exit 1
fi
if [[ $direction == "+" ]]; then
next=$((current + step))
((next <= 100)) || next=100
else
next=$((current - step))
((next >= 0)) || next=0
fi
pactl set-sink-mute "$sink" 0
pactl set-sink-volume "$sink" "${next}%"
else
echo "Unknown volume action: $action"
exit 1
fi
percent=$(volume_percent)
if volume_muted || ((${percent:-0} == 0)); then
icon="volume-muted"
else
icon="volume-high"
fi
omarchy-osd -i "$icon" -p "${percent:-0}"
-54
View File
@@ -1,54 +0,0 @@
#!/bin/bash
# omarchy:summary=Print PulseAudio sink availability for the shell
# omarchy:group=audio
# A speaker tuning is a virtual sink in front of the real speakers. Both exist
# in the graph, but selecting the physical one would only bypass the tuning, so
# report it unavailable and keep it out of the output list.
fronted="$(omarchy-audio-tuning fronted-sink 2>/dev/null || true)"
pactl list sinks 2>/dev/null | awk -v fronted="$fronted" '
function emit_sink() {
if (name == "") return
if (fronted != "" && name == fronted) {
print name "\t0"
return
}
print name "\t" ((port_count == 0 || available) ? 1 : 0)
}
/^Sink #/ {
emit_sink()
name = ""
in_ports = 0
port_count = 0
available = 0
next
}
/^[[:space:]]*Name:/ {
name = $2
next
}
/^[[:space:]]*Ports:$/ {
in_ports = 1
next
}
in_ports && /^\tActive Port:/ {
in_ports = 0
next
}
in_ports && /^\t\t/ {
port_count++
if ($0 !~ /not available/) available = 1
next
}
END {
emit_sink()
}
'
-20
View File
@@ -1,20 +0,0 @@
#!/bin/bash
# omarchy:summary=Cycle to the next media source and transfer playback when the current source is playing
# omarchy:args=[next|previous]
# omarchy:examples=omarchy audio source switch | omarchy-audio-source-switch previous
direction="${1:-next}"
case "$direction" in
next)
omarchy-shell media sourceSwitch
;;
previous)
omarchy-shell media sourceSwitchPrevious
;;
*)
echo "Usage: omarchy-audio-source-switch [next|previous]" >&2
exit 1
;;
esac
-357
View File
@@ -1,357 +0,0 @@
#!/bin/bash
# omarchy:summary=Manage the speaker tuning for this laptop
# omarchy:args=<on|off|status|match|fronted-sink> [--force]
# omarchy:group=audio
# omarchy:examples=omarchy audio tuning status | omarchy audio tuning on | omarchy audio tuning off
set -uo pipefail
tunings_dir="$OMARCHY_PATH/default/audio/tunings"
config_home="${XDG_CONFIG_HOME:-$HOME/.config}"
# The tuning is hosted by its own PipeWire client, under its own config name, so
# switching it needs no audio restart -- a restart drops every PulseAudio client's
# connection, and applications that do not reconnect (Spotify) then have to be
# restarted by hand. The name is deliberately not PipeWire's stock
# filter-chain.conf, which merges every fragment in filter-chain.conf.d/ and would
# make this service host unrelated user filters too.
host_config_name=omarchy-speaker-tuning.conf
host_config="$config_home/pipewire/$host_config_name"
host_source="$OMARCHY_PATH/default/audio/filter-chain-host.conf"
fragment="$config_home/pipewire/$host_config_name.d/90-tuning.conf"
unit_name=omarchy-speaker-tuning.service
unit="$config_home/systemd/user/$unit_name"
unit_source="$OMARCHY_PATH/default/systemd/user/$unit_name"
# Earlier revisions loaded the tuning into the daemon, as a WirePlumber smart
# filter, or into the shared filter-chain.conf.d namespace. Remove all three so
# they cannot be loaded alongside the current one.
stale_daemon="$config_home/pipewire/pipewire.conf.d/90-omarchy-speaker-tuning.conf"
stale_wireplumber="$config_home/wireplumber/wireplumber.conf.d/90-omarchy-speaker-tuning.conf"
stale_shared="$config_home/pipewire/filter-chain.conf.d/90-omarchy-speaker-tuning.conf"
sink_name=omarchy_speaker_tuning
action="${1:-status}"
force=0
[[ ${2:-} == "--force" ]] && force=1
sink_matching() {
pactl list sinks short 2>/dev/null | awk -v p="$1" '$2 ~ p {print $2; exit}'
}
# Dell keys its Cirrus speaker firmware on the DMI product SKU, which makes it the
# most precise identifier available for these machines -- narrower than a product
# name, and it distinguishes models whose names differ only by marketing. Compared
# case-insensitively against an exact SKU, never a substring, so a tuning cannot
# accidentally widen to a whole product line.
sku_matches() {
local sku want
sku="$(cat /sys/class/dmi/id/product_sku 2>/dev/null)"
[[ -n $sku ]] || return 1
for want in "$@"; do
[[ ${sku,,} == "${want,,}" ]] && return 0
done
return 1
}
dmi_matches() {
local want
for want in "$@"; do
omarchy-hw-match "$want" 2>/dev/null && return 0
done
return 1
}
# Print the tuning directory matching this laptop, if any. Matching is data, not
# code: a tuning declares the DMI string it belongs to and the sink it expects, so
# most tunings can be added as a directory with no new script. A tuning whose
# hardware needs a sharper test can set match_command to any predicate instead.
tuning_match() {
local dir
for dir in "$tunings_dir"/*/; do
[[ -r $dir/tuning.conf ]] || continue
unset match_dmi match_sku match_command sink_pattern
# shellcheck disable=SC1090
source "$dir/tuning.conf"
# Deliberately does not look at the live audio graph. The install hooks run in
# the ISO chroot with no audio server, and a match that depended on a present
# sink would come back empty there -- so the machine would get neither the LV2
# dependency nor the tuning, and nothing would retry.
# A tuning may list several models it has been validated on. match_dmi and
# match_sku are arrays, so a plain string still works as a single entry.
if [[ -n ${match_command:-} ]]; then
"$match_command" 2>/dev/null || continue
elif [[ -n ${match_sku:-} ]]; then
sku_matches "${match_sku[@]}" || continue
elif [[ -n ${match_dmi:-} ]]; then
dmi_matches "${match_dmi[@]}" || continue
else
continue
fi
# Required whichever way the tuning matched: the graph's target sink is
# substituted from it, so a tuning without one cannot be installed and must
# not be reported as a match.
[[ -n ${sink_pattern:-} ]] || continue
printf '%s\n' "${dir%/}"
return 0
done
return 1
}
# The physical sink the matched tuning is built for, taken from the tuning's own
# sink_pattern rather than a hard-coded regex, so hardware with a different sink
# name needs no change here.
tuned_hardware_sink() {
local dir found
dir="$(tuning_match)" || return 1
unset sink_pattern
# shellcheck disable=SC1090
source "$dir/tuning.conf"
[[ -n ${sink_pattern:-} ]] || return 1
found="$(sink_matching "$sink_pattern")"
[[ -n $found ]] || return 1
printf '%s\n' "$found"
}
tuning_present() {
pactl list sinks short 2>/dev/null | awk '{print $2}' | grep -x "$sink_name" >/dev/null
}
# Only real application streams may be moved. A filter-chain's own output is also
# a sink input but carries no application.name, and moving it would rewire the
# tuning itself.
app_streams() {
pactl list sink-inputs 2>/dev/null | awk '
/^Sink Input #/ {id = substr($3, 2)}
/application\.name = / {
app = $0
sub(/.*application\.name = "/, "", app)
sub(/"$/, "", app)
if (app != "EasyEffects") print id
}'
}
move_apps_to() {
local target="$1" id
for id in $(app_streams); do
pactl move-sink-input "$id" "$target" 2>/dev/null || true
done
}
# WirePlumber can link the output elsewhere if the target is missing when the host
# starts. node.dont-fallback guards against it, but verify rather than assume.
tuning_downstream_sink() {
omarchy-audio-output-sink "$sink_name" 2>/dev/null
}
easyeffects_running() {
pactl list sinks short 2>/dev/null | awk '{print $2}' | grep -x easyeffects_sink >/dev/null ||
pgrep -u "$(id -u)" -x easyeffects >/dev/null 2>&1 ||
systemctl --user is-active --quiet easyeffects.service 2>/dev/null
}
# Unloading a daemon-loaded drop-in is the one case that still needs an audio
# restart, because the daemon only reads its own config at startup.
drop_stale_daemon_config() {
[[ -e $stale_daemon || -e $stale_wireplumber ]] || return 0
rm -f "$stale_daemon" "$stale_wireplumber"
omarchy-restart-audio >/dev/null 2>&1
local _
for _ in {1..40}; do
pactl info >/dev/null 2>&1 && break
sleep 0.25
done
}
case "$action" in
match)
tuning_match
;;
fronted-sink)
# The tuning is a virtual sink in front of the real speakers, so both exist in
# the graph. Selecting the physical one would only bypass the tuning, so
# callers keep it out of the output list while the tuning is up. This answers
# "is a tuning in place", not "where should volume go" -- for the latter see
# omarchy-audio-output-sink, which follows the current default output.
tuning_present || exit 1
tuned_hardware_sink
;;
status)
if [[ -r $fragment ]]; then
echo "Installed: yes ($fragment)"
else
echo "Installed: no"
fi
# Both is-active and is-enabled print their answer *and* exit non-zero when
# negative, so a "|| echo" fallback prints it twice.
host_state="$(systemctl --user is-active "$unit_name" 2>/dev/null)"
host_enabled="$(systemctl --user is-enabled "$unit_name" 2>/dev/null)"
echo "Host service: ${host_state:-inactive} (${host_enabled:-disabled})"
if tuning_present; then
echo "Tuning sink: present"
else
echo "Tuning sink: absent"
fi
echo "Default sink: $(pactl get-default-sink 2>/dev/null)"
if dir="$(tuning_match)"; then
unset description
# shellcheck disable=SC1090
source "$dir/tuning.conf"
echo "Matches: ${description:-?} ($(basename "$dir"))"
else
echo "Matches: nothing ships for this laptop"
fi
;;
off)
if [[ ! -r $fragment && ! -r $unit && ! -r $stale_daemon && ! -r $stale_wireplumber &&
! -r $stale_shared ]]; then
echo "No speaker tuning installed."
exit 0
fi
speakers="$(tuned_hardware_sink)" || speakers=""
systemctl --user disable --now "$unit_name" >/dev/null 2>&1
rm -f "$fragment" "$host_config" "$unit" "$stale_shared"
rmdir "$config_home/pipewire/$host_config_name.d" 2>/dev/null
systemctl --user daemon-reload >/dev/null 2>&1
drop_stale_daemon_config
for _ in {1..20}; do
tuning_present || break
sleep 0.25
done
if [[ -n $speakers ]]; then
pactl set-default-sink "$speakers" >/dev/null 2>&1
# Streams left on the vanished tuning sink reconnect wherever PipeWire puts
# them, which is not necessarily the speakers.
move_apps_to "$speakers"
fi
echo "Speaker tuning removed."
;;
on)
[[ -d $tunings_dir ]] || {
echo "No tunings shipped at $tunings_dir" >&2
exit 1
}
selected="$(tuning_match)" || {
echo "No speaker tuning matches this laptop."
exit 0
}
unset description sink_pattern
# shellcheck disable=SC1090
source "$selected/tuning.conf"
# At first-run the session is up but the sink can still be settling.
for _ in {1..20}; do
speaker_sink="$(sink_matching "$sink_pattern")"
[[ -n $speaker_sink ]] && break
sleep 0.5
done
[[ -n ${speaker_sink:-} ]] || {
echo "A tuning applies to this laptop but no sink matching $sink_pattern" >&2
echo "is present, so there is no audio server yet. Re-run after login:" >&2
echo " omarchy audio tuning on" >&2
exit 1
}
if easyeffects_running; then
cat >&2 <<'EOF'
EasyEffects is running. It moves any stream that follows the default sink to its
own sink, so a tuning installed now would be bypassed.
Stop it first: systemctl --user disable --now easyeffects.service
EOF
exit 1
fi
# Every tuning ends in a limiter, which is an LV2 plugin. Without it the graph
# fails to instantiate and the tuning sink never appears.
ls /usr/lib/lv2/lsp-plugins.lv2/limiter_stereo.ttl >/dev/null 2>&1 || {
echo "lsp-plugins-lv2 is required for the tuning limiter." >&2
exit 1
}
rendered="$(mktemp)"
trap 'rm -f "$rendered"' EXIT
sed "s|@SPEAKER_SINK@|$speaker_sink|g" "$selected/filter-chain.conf" >"$rendered"
# Everything that makes the tuning current has to match, not just the graph:
# an active-but-disabled service disappears at next login, and a stale unit
# file would shadow later fixes to the shipped one indefinitely.
if ((!force)) && [[ -r $fragment ]] && cmp -s "$rendered" "$fragment" &&
[[ -r $host_config ]] && cmp -s "$host_source" "$host_config" &&
[[ -r $unit ]] && cmp -s "$unit_source" "$unit" &&
systemctl --user is-active --quiet "$unit_name" 2>/dev/null &&
systemctl --user is-enabled --quiet "$unit_name" 2>/dev/null &&
[[ "$(tuning_downstream_sink)" == "$speaker_sink" ]]; then
echo "Speaker tuning already current: $description"
exit 0
fi
drop_stale_daemon_config
rm -f "$stale_shared"
install -Dm644 "$host_source" "$host_config"
install -Dm644 "$rendered" "$fragment"
install -Dm644 "$unit_source" "$unit"
systemctl --user daemon-reload >/dev/null 2>&1
systemctl --user enable "$unit_name" >/dev/null 2>&1
systemctl --user restart "$unit_name" >/dev/null 2>&1
echo "Installed speaker tuning: $description"
for _ in {1..40}; do
tuning_present && break
sleep 0.25
done
if ! tuning_present; then
systemctl --user disable --now "$unit_name" >/dev/null 2>&1
rm -f "$fragment" "$host_config" "$unit"
systemctl --user daemon-reload >/dev/null 2>&1
echo "Tuning sink never appeared, so it was removed. Audio is untouched." >&2
echo "Check: systemctl --user status $unit_name" >&2
exit 1
fi
# Confirm the output really landed on the sink this tuning was measured for.
for _ in {1..20}; do
[[ "$(tuning_downstream_sink)" == "$speaker_sink" ]] && break
sleep 0.25
done
downstream="$(tuning_downstream_sink)"
if [[ $downstream != "$speaker_sink" ]]; then
systemctl --user disable --now "$unit_name" >/dev/null 2>&1
rm -f "$fragment" "$host_config" "$unit"
systemctl --user daemon-reload >/dev/null 2>&1
echo "The tuning output linked to ${downstream:-nothing} instead of" >&2
echo "$speaker_sink, so it was removed rather than left tuning the wrong" >&2
echo "device. Audio is untouched." >&2
exit 1
fi
pactl set-default-sink "$sink_name" >/dev/null 2>&1
# A default sink only captures newly created streams, so anything already
# playing would keep bypassing the tuning until its app was restarted.
move_apps_to "$sink_name"
echo "Speakers now play through the tuning."
;;
*)
echo "Usage: omarchy-audio-tuning <on|off|status|match|fronted-sink> [--force]" >&2
exit 2
;;
esac
-403
View File
@@ -1,403 +0,0 @@
#!/bin/bash
# omarchy:summary=Configure the bar and its widget layout
# omarchy:group=bar
# omarchy:args=use <id> | reset | defaults | position <top|bottom|left|right> | transparent <true|false|toggle> | put <id> [placement] | move <id> [placement] | set <id> <key> <value> [--json] [placement]
# omarchy:examples=omarchy bar use local.neon-bar | omarchy bar put omarchy.keyboard-layout --after omarchy.clock | omarchy bar move omarchy.clock --section center --index 0 | omarchy bar set omarchy.clock format HH:mm
set -euo pipefail
source omarchy-shell-config
usage() {
cat <<USAGE
Usage: omarchy bar <command> [args...]
use <id> Use a bar option as the active bar
reset Return to the built-in Omarchy bar
defaults Restore the default bar and service widgets
position <top|bottom|left|right> Bar position
transparent <true|false|toggle> Bar transparency
put <id> [placement] Put a widget on the bar, leaving one
that is already there where it is
move <id> [placement] Move a widget within or between sections
set <id> <key> <value> [--json] [placement]
Set a per-widget option
Placement:
--section <left|center|right> Target section
--index <n> Target index
--before <id> Insert before a widget
--after <id> Insert after a widget
--from-section <section> Source section
--from-index <n> Source index
Enable and disable widgets with 'omarchy plugin enable' and
'omarchy plugin disable'.
'put' places a widget the way 'plugin enable' does, but leaves one that is
already on the bar where it is, and falls back to the widget's usual spot when
--before / --after names a widget the bar does not carry.
Examples:
omarchy bar use local.neon-bar
omarchy bar put omarchy.keyboard-layout --after omarchy.clock
omarchy bar move omarchy.media left
omarchy bar move omarchy.clock --section center --index 0
omarchy bar set omarchy.clock format HH:mm
USAGE
}
# ------------------------------------------------------------------ validation
bar_option_exists() {
omarchy-plugin-catalog | jq -e --arg id "$1" '
any(.[]; (.kinds | index("bar")) and .barPath != null and .id == $id)
' >/dev/null
}
validate_section() {
[[ $1 =~ ^(left|center|right)$ ]] || fail "section must be left, center, or right"
}
validate_index() {
[[ $1 =~ ^[0-9]+$ ]] || fail "index must be a non-negative integer"
}
# --------------------------------------------------------------------- placement
PLACEMENT_SECTION=""
PLACEMENT_INDEX=""
PLACEMENT_BEFORE=""
PLACEMENT_AFTER=""
PLACEMENT_FROM_SECTION=""
PLACEMENT_FROM_INDEX=""
parse_placement() {
while (( $# > 0 )); do
case "$1" in
--section)
PLACEMENT_SECTION="${2:-}"
validate_section "$PLACEMENT_SECTION"
shift 2
;;
--index)
PLACEMENT_INDEX="${2:-}"
validate_index "$PLACEMENT_INDEX"
shift 2
;;
--before)
PLACEMENT_BEFORE="${2:-}"
[[ -n $PLACEMENT_BEFORE ]] || fail "--before requires a widget id"
shift 2
;;
--after)
PLACEMENT_AFTER="${2:-}"
[[ -n $PLACEMENT_AFTER ]] || fail "--after requires a widget id"
shift 2
;;
--from-section)
PLACEMENT_FROM_SECTION="${2:-}"
validate_section "$PLACEMENT_FROM_SECTION"
shift 2
;;
--from-index)
PLACEMENT_FROM_INDEX="${2:-}"
validate_index "$PLACEMENT_FROM_INDEX"
shift 2
;;
-h | --help)
usage
exit 0
;;
*)
fail "unknown option: $1"
;;
esac
done
[[ -z $PLACEMENT_BEFORE || -z $PLACEMENT_AFTER ]] || fail "use only one of --before or --after"
}
placement_json() {
jq -cn \
--arg section "$PLACEMENT_SECTION" \
--arg index "$PLACEMENT_INDEX" \
--arg before "$PLACEMENT_BEFORE" \
--arg after "$PLACEMENT_AFTER" \
--arg fromSection "$PLACEMENT_FROM_SECTION" \
--arg fromIndex "$PLACEMENT_FROM_INDEX" '
{}
+ (if $section == "" then {} else {section: $section} end)
+ (if $index == "" then {} else {index: ($index | tonumber)} end)
+ (if $before == "" then {} else {before: $before} end)
+ (if $after == "" then {} else {after: $after} end)
+ (if $fromSection == "" then {} else {fromSection: $fromSection} end)
+ (if $fromIndex == "" then {} else {fromIndex: ($fromIndex | tonumber)} end)
'
}
# -------------------------------------------------------------------- commands
cmd_use() {
local plugin="${1:-}"
[[ -n $plugin ]] || fail "bar option id is required"
(( $# == 1 )) || fail "use takes a single bar option id"
if [[ $plugin == "default" || $plugin == "built-in" ]]; then
plugin="omarchy.bar"
fi
bar_option_exists "$plugin" || fail "$plugin is not a known bar option; run 'omarchy plugin list'"
if [[ $plugin == "omarchy.bar" ]]; then
commit "$NORMALIZE | del(.bar.id)"
else
commit "$NORMALIZE | .bar.id = \$plugin" --arg plugin "$plugin"
fi
echo "Using $plugin as the active bar"
}
cmd_defaults() {
(( $# == 0 )) || fail "defaults does not take arguments"
local optional_widgets="[]"
local service widget
for service in dropbox tailscale; do
if "omarchy-installed-service-$service"; then
widget=$(jq -cn \
--arg id "omarchy.$service" \
--arg section "$(bar_widget_default_section "omarchy.$service")" \
'{id: $id, section: $section}')
optional_widgets=$(jq -c --argjson widget "$widget" '. + [$widget]' <<<"$optional_widgets")
fi
done
# This remains one file mutation so it also works during the headless
# Quattro upgrade and cannot race the shell's in-memory config.
commit "$NORMALIZE
| .bar = \$defaults[0].bar
| def entry_id: if type == \"object\" then (.id // \"\" | tostring) else tostring end;
def anchor_for(\$section): { left: \"omarchy.workspaces\", center: \"omarchy.weather\", right: \"omarchy.tray\" }[\$section];
reduce \$widgets[] as \$widget (.;
.bar.layout.left = (.bar.layout.left | map(select(entry_id != \$widget.id)))
| .bar.layout.center = (.bar.layout.center | map(select(entry_id != \$widget.id)))
| .bar.layout.right = (.bar.layout.right | map(select(entry_id != \$widget.id)))
| (.bar.layout[\$widget.section] | map(entry_id) | index(anchor_for(\$widget.section))) as \$anchor
| (\$anchor | if . == null then (.bar.layout[\$widget.section] | length) else . + 1 end) as \$index
| .bar.layout[\$widget.section] = (
.bar.layout[\$widget.section][0:\$index]
+ [{id: \$widget.id}]
+ .bar.layout[\$widget.section][\$index:]
)
)
" \
--slurpfile defaults "$DEFAULTS_FILE" \
--argjson widgets "$optional_widgets"
echo "Restored the default Omarchy bar"
}
cmd_position() {
local position="${1:-}"
[[ -n $position ]] || fail "position is required"
(( $# == 1 )) || fail "position takes a single value"
[[ $position =~ ^(top|bottom|left|right)$ ]] || fail "position must be top, bottom, left, or right"
commit "$NORMALIZE | .bar.position = \$position" --arg position "$position"
echo "Bar position set to $position"
}
cmd_transparent() {
local transparent="${1:-}"
[[ -n $transparent ]] || fail "transparent is required"
(( $# == 1 )) || fail "transparent takes a single value"
[[ $transparent =~ ^(true|false|toggle)$ ]] || fail "transparent must be true, false, or toggle"
if [[ $transparent == "toggle" ]]; then
commit "$NORMALIZE | .bar.transparent = (.bar.transparent != true)"
echo "Bar transparency toggled"
else
commit "$NORMALIZE | .bar.transparent = \$transparent" --argjson transparent "$transparent"
echo "Bar transparency set to $transparent"
fi
}
bar_widget_default_section() {
local catalog
catalog=$(omarchy-plugin-catalog 2>/dev/null) || {
echo "center"
return 0
}
jq -r --arg id "$1" '
map(select(.id == $id))[0].barWidget.defaultSection // "center"
| if IN("left", "center", "right") then . else "center" end
' <<<"$catalog"
}
# Asks the shell to place a widget, waiting out one still coming up. Answers 0
# with the reply in PUT_RESULT, 1 when there was no shell to ask. Only an
# absent shell is carried on from: omarchy-migrate marks a 0 return as done.
PUT_RESULT=""
SHELL_ANSWERED=0
ask_to_put() {
local id="$1" placement="$2" attempt absent=0
for (( attempt = 0; attempt < ${OMARCHY_SHELL_READY_ATTEMPTS:-50}; attempt++ )); do
if PUT_RESULT=$(omarchy-shell shell putBarWidget "$id" "$placement" 2>&1); then
SHELL_ANSWERED=1
[[ $PUT_RESULT == "not ready" ]] || return 0
elif [[ $PUT_RESULT == *"not ready"* ]]; then
SHELL_ANSWERED=1
elif [[ $PUT_RESULT == *"is not running"* ]]; then
# Answered once and now gone: it stopped mid-request.
if (( SHELL_ANSWERED )); then
fail "omarchy-shell did not become ready; $id was not put on the bar"
fi
# A shell being spawned has no socket yet, and nothing says a launch is
# under way, so give one a few seconds to turn up.
if (( ++absent >= ${OMARCHY_SHELL_ABSENT_ATTEMPTS:-30} )); then
echo "omarchy-shell is not running; $id was not put on the bar" >&2
return 1
fi
else
fail "could not put $id on the bar: $PUT_RESULT"
fi
sleep 0.1
done
fail "omarchy-shell did not become ready; $id was not put on the bar"
}
# Placement lives in the shell, which owns the config it has in memory. Putting
# a widget therefore asks the shell rather than editing the file behind it.
cmd_put() {
local id="${1:-}"
[[ -n $id ]] || fail "put requires a widget id"
shift
local positional_section=""
if (( $# > 0 )) && [[ $1 != --* ]]; then
positional_section="$1"
validate_section "$positional_section"
shift
fi
parse_placement "$@"
[[ -z $positional_section || -z $PLACEMENT_SECTION ]] ||
fail "specify a section positionally or with --section, not both"
[[ -z $positional_section ]] || PLACEMENT_SECTION="$positional_section"
[[ -z $PLACEMENT_FROM_SECTION && -z $PLACEMENT_FROM_INDEX ]] ||
fail "put does not accept --from-section or --from-index"
local placement
placement=$(placement_json)
ask_to_put "$id" "$placement" || return 0
# An update runs migrations before it restarts the shell, so this one can
# predate the fallback. Ask it again without the neighbour it cannot find.
if [[ $PUT_RESULT == "could not find target widget"* ]]; then
PLACEMENT_BEFORE=""
PLACEMENT_AFTER=""
ask_to_put "$id" "$(placement_json)" || return 0
fi
[[ $PUT_RESULT != "unknown" ]] || fail "$id is not a known widget; run 'omarchy plugin list'"
[[ $PUT_RESULT == "ok" ]] || fail "$PUT_RESULT"
# Says nothing about whether it had to be placed: a widget already on the bar
# is left where it is, and both outcomes are the same answer to the caller.
echo "$id is on the bar"
}
cmd_move() {
local id="${1:-}"
[[ -n $id ]] || fail "move requires a widget id"
shift
local positional_section=""
if (( $# > 0 )) && [[ $1 != --* ]]; then
positional_section="$1"
validate_section "$positional_section"
shift
fi
parse_placement "$@"
[[ -z $positional_section || -z $PLACEMENT_SECTION ]] ||
fail "specify a section positionally or with --section, not both"
[[ -z $positional_section || -z $PLACEMENT_INDEX ]] ||
fail "specify a section positionally or use --index, not both"
[[ -z $positional_section || -z $PLACEMENT_BEFORE ]] ||
fail "specify a section positionally or use --before, not both"
[[ -z $positional_section || -z $PLACEMENT_AFTER ]] ||
fail "specify a section positionally or use --after, not both"
[[ -z $positional_section ]] || PLACEMENT_SECTION="$positional_section"
local result
result=$(omarchy-shell shell moveBarWidget "$id" "$(placement_json)")
[[ $result == "ok" ]] || fail "$result"
echo "Moved $id"
}
cmd_set() {
local id="${1:-}"
local key="${2:-}"
local value="${3:-}"
[[ -n $id ]] || fail "set requires a widget id"
[[ -n $key ]] || fail "set requires a setting key"
(( $# >= 3 )) || fail "set requires a value"
shift 3
local value_is_json="false"
if (( $# > 0 )) && [[ $1 == "--json" ]]; then
value_is_json="true"
shift
fi
parse_placement "$@"
[[ -z $PLACEMENT_BEFORE && -z $PLACEMENT_AFTER ]] ||
fail "set does not accept --before or --after"
local value_json
if [[ $value_is_json == "true" ]]; then
value_json=$(jq -cn --argjson value "$value" '$value') ||
fail "invalid JSON value: $value"
else
value_json=$(jq -cn --arg value "$value" '$value')
fi
local result
result=$(omarchy-shell shell setBarWidget "$id" "$key" "$value_json" "$(placement_json)")
[[ $result == "ok" ]] || fail "$result"
echo "Set $key on $id"
}
# --------------------------------------------------------------------- dispatch
command="${1:-}"
(( $# > 0 )) && shift || true
case "$command" in
use)
cmd_use "$@"
;;
reset)
(( $# == 0 )) || fail "reset does not take arguments"
cmd_use omarchy.bar
;;
defaults)
cmd_defaults "$@"
;;
position)
cmd_position "$@"
;;
transparent)
cmd_transparent "$@"
;;
put)
cmd_put "$@"
;;
move)
cmd_move "$@"
;;
set)
cmd_set "$@"
;;
-h | --help | help | "")
usage
;;
*)
fail "unknown command: $command"
;;
esac
-125
View File
@@ -1,125 +0,0 @@
#!/bin/bash
# omarchy:summary=Choose a legible transparent bar text color
# omarchy:hidden=true
set -e
position=${1:-top}
bar_size=${2:-}
text_color=${3:-}
background_color=${4:-}
background_path=""
screen_size=""
shift 4 2>/dev/null || true
while (($# > 0)); do
case "$1" in
--background)
background_path=${2:-}
shift 2
;;
--screen)
screen_size=${2:-}
shift 2
;;
*)
shift
;;
esac
done
valid_hex() {
[[ $1 =~ ^#[0-9A-Fa-f]{6}$ ]]
}
fallback() {
printf '%s\n' "$text_color"
exit 0
}
contrast() {
local color="$1"
local sample="$2"
awk -v fg="$color" -v bg="$sample" '
function channel(hex, start) {
return strtonum("0x" substr(hex, start, 2)) / 255
}
function linear(c) {
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ^ 2.4
}
function luminance(hex, r, g, b) {
r = linear(channel(hex, 2))
g = linear(channel(hex, 4))
b = linear(channel(hex, 6))
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
BEGIN {
l1 = luminance(fg)
l2 = luminance(bg)
if (l1 < l2) {
t = l1
l1 = l2
l2 = t
}
printf "%.6f\n", (l1 + 0.05) / (l2 + 0.05)
}
'
}
valid_hex "$text_color" || fallback
valid_hex "$background_color" || fallback
[[ $position =~ ^(top|bottom|left|right)$ ]] || fallback
[[ $bar_size =~ ^[0-9]+$ ]] || fallback
omarchy-cmd-present magick || fallback
if [[ -z $background_path ]]; then
background_path=$(readlink -f "$HOME/.local/state/omarchy/current/background" 2>/dev/null || true)
fi
[[ -f $background_path ]] || fallback
if [[ -z $screen_size ]]; then
if omarchy-cmd-present hyprctl && omarchy-cmd-present jq; then
screen_size=$(hyprctl monitors -j 2>/dev/null | jq -r '.[0] | "\(.width)x\(.height)"' 2>/dev/null || true)
fi
fi
[[ $screen_size =~ ^([0-9]+)x([0-9]+)$ ]] || fallback
screen_width=${BASH_REMATCH[1]}
screen_height=${BASH_REMATCH[2]}
((screen_width > 0 && screen_height > 0 && bar_size > 0)) || fallback
case "$position" in
top)
crop="${screen_width}x${bar_size}+0+0"
;;
bottom)
crop_y=$((screen_height - bar_size))
((crop_y >= 0)) || fallback
crop="${screen_width}x${bar_size}+0+${crop_y}"
;;
left)
crop="${bar_size}x${screen_height}+0+0"
;;
right)
crop_x=$((screen_width - bar_size))
((crop_x >= 0)) || fallback
crop="${bar_size}x${screen_height}+${crop_x}+0"
;;
esac
pixel=$(magick "$background_path" -auto-orient \
-resize "${screen_width}x${screen_height}^" \
-gravity center -extent "${screen_width}x${screen_height}" \
-gravity NorthWest -crop "$crop" +repage \
-resize '1x1!' -format '%[fx:int(255*r)],%[fx:int(255*g)],%[fx:int(255*b)]' info:- 2>/dev/null || true)
[[ $pixel =~ ^([0-9]+),([0-9]+),([0-9]+)$ ]] || fallback
sample=$(printf '#%02x%02x%02x' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}")
text_contrast=$(contrast "$text_color" "$sample")
background_contrast=$(contrast "$background_color" "$sample")
awk -v text="$text_contrast" -v background="$background_contrast" 'BEGIN { exit !(background > text) }' \
&& printf '%s\n' "$background_color" \
|| printf '%s\n' "$text_color"
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
# omarchy:summary=Returns the battery full capacity in Wh (rounded to whole number).
battery_info=$(upower -i $(upower -e | grep BAT))
echo "$battery_info" | awk '/energy-full:/ {
printf "%d", $2
exit
}'
-17
View File
@@ -1,17 +0,0 @@
#!/bin/bash
# omarchy:summary=Send the low battery warning notification and run battery-low hooks.
# omarchy:args=<percentage>
# omarchy:hidden=true
set -euo pipefail
if (($# != 1)); then
echo "Usage: omarchy-battery-low <percentage>" >&2
exit 1
fi
level=$1
omarchy-notification-send -g 󱐋 -u critical "Time to recharge!" "Battery is down to ${level}%" -i battery-caution -t 30000
omarchy-hook battery-low "$level"
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
# omarchy:summary=Designed to be run by systemd timer every 30 seconds and alerts if battery is low
# omarchy:hidden=true
BATTERY_THRESHOLD=10
NOTIFICATION_FLAG="/run/user/$UID/omarchy_battery_notified"
BATTERY_LEVEL=$(omarchy-battery-remaining)
BATTERY_STATE=$(upower -i $(upower -e | grep 'BAT') | grep -E "state" | awk '{print $2}')
send_notification() {
notify-send -u critical "󱐋 Time to recharge!" "Battery is down to ${1}%" -i battery-caution -t 30000
omarchy-hook battery-low "$1"
}
if [[ -n $BATTERY_LEVEL && $BATTERY_LEVEL =~ ^[0-9]+$ ]]; then
if [[ $BATTERY_STATE == "discharging" ]] && (( BATTERY_LEVEL <= BATTERY_THRESHOLD )); then
if [[ ! -f $NOTIFICATION_FLAG ]]; then
send_notification $BATTERY_LEVEL
touch $NOTIFICATION_FLAG
fi
else
rm -f $NOTIFICATION_FLAG
fi
fi
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# omarchy:summary=Returns the battery percentage remaining as an integer.
upower -i $(upower -e | grep BAT) | awk '/percentage/ {
print int($2)
exit
}'
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
# omarchy:summary=Returns the battery time remaining (to empty or full) in a compact format.
battery_info=$(upower -i $(upower -e | grep BAT))
echo "$battery_info" | awk '/time to (empty|full)/ {
value = $4
unit = $5
if (unit ~ /^minute/) {
printf "%dm", int(value)
} else {
hours = int(value)
minutes = int((value - hours) * 60)
if (minutes > 0) {
printf "%dh %dm", hours, minutes
} else {
printf "%dh", hours
}
}
exit
}'
+16 -126
View File
@@ -1,137 +1,27 @@
#!/bin/bash #!/bin/bash
# omarchy:summary=Returns a formatted battery status string with percentage and power draw/charge. # omarchy:summary=Returns a formatted battery status string with percentage and power draw/charge.
# omarchy:args=[--shell]
shell_output=false battery_info=$(upower -i $(upower -e | grep BAT))
power_supply_path="${OMARCHY_POWER_SUPPLY_PATH:-/sys/class/power_supply}"
case "${1:-}" in percentage=$(echo "$battery_info" | awk '/percentage/ {
"") print int($2)
;; exit
--shell)
shell_output=true
;;
*)
echo "Usage: omarchy-battery-status [--shell]" >&2
exit 2
;;
esac
battery=$(upower -e 2>/dev/null | grep BAT | head -n 1)
[[ -z $battery ]] && exit 0
battery_info=$(upower -i "$battery")
percentage=$(awk '/percentage/ { print int($2); exit }' <<<"$battery_info")
capacity=$(awk '/energy-full:/ { printf "%d", $2; exit }' <<<"$battery_info")
time_remaining=$(awk '/time to (empty|full)/ {
value = $4
unit = $5
if (unit ~ /^minute/) {
printf "%dm", int(value)
} else {
hours = int(value)
minutes = int((value - hours) * 60)
if (minutes > 0) {
printf "%dh %dm", hours, minutes
} else {
printf "%dh", hours
}
}
exit
}' <<<"$battery_info")
power_rate_raw=$(awk '/energy-rate/ { print $2; exit }' <<<"$battery_info")
native_path=$(awk '/native-path/ { print $2; exit }' <<<"$battery_info")
battery_path="$power_supply_path/$native_path"
# UPower's energy-rate can lag the kernel telemetry by tens of seconds. Use
# the instantaneous sysfs reading when available so the open panel stays live.
if [[ -r $battery_path/power_now ]]; then
power_rate_raw=$(awk -v microwatts="$(<"$battery_path/power_now")" 'BEGIN { print microwatts / 1000000 }')
elif [[ -r $battery_path/current_now && -r $battery_path/voltage_now ]]; then
power_rate_raw=$(awk \
-v microamps="$(<"$battery_path/current_now")" \
-v microvolts="$(<"$battery_path/voltage_now")" \
'BEGIN { print microamps * microvolts / 1000000000000 }')
fi
power_rate=$(awk -v rate="${power_rate_raw:-0}" 'BEGIN {
rounded = sprintf("%.1f", rate)
sub(/\.0$/, "", rounded)
print rounded
}') }')
state=$(awk '/state/ { print $2; exit }' <<<"$battery_info")
threshold_start=$(awk '/charge-start-threshold:/ { gsub(/%/, "", $2); print int($2); exit }' <<<"$battery_info")
threshold_end=$(awk '/charge-end-threshold:/ { gsub(/%/, "", $2); print int($2); exit }' <<<"$battery_info")
[[ -z $threshold_end ]] && threshold_end=$(cat "$power_supply_path"/BAT*/charge_control_end_threshold 2>/dev/null | head -1) power_rate=$(echo "$battery_info" | awk '/energy-rate/ {
[[ -z $threshold_start ]] && threshold_start=$(cat "$power_supply_path"/BAT*/charge_control_start_threshold 2>/dev/null | head -1) rounded = sprintf("%.1f", $2)
sub(/\.0$/, "", rounded)
print rounded
exit
}')
ac_online=false state=$(echo "$battery_info" | awk '/state/ { print $2; exit }')
for supply in "$power_supply_path"/*; do time_remaining=$(omarchy-battery-remaining-time)
[[ -r $supply/type ]] || continue capacity=$(omarchy-battery-capacity)
[[ $(<"$supply/type") == "Mains" ]] || continue
[[ -r $supply/online ]] || continue
if [[ $(<"$supply/online") == "1" ]]; then if [[ $state == "charging" ]]; then
ac_online=true echo "󰁹 Battery ${percentage}% · ${time_remaining} to full ·  ${power_rate}W / ${capacity}Wh"
break
fi
done
charge_idle=false
if awk -v rate="${power_rate_raw:-0}" 'BEGIN { exit !(rate <= 0.2) }'; then
charge_idle=true
fi
charge_holding=false
if [[ $ac_online == "true" && -n $threshold_end ]]; then
if [[ $state == "pending-charge" ]]; then
charge_holding=true
elif [[ $state == "fully-charged" ]] && (( percentage < 99 )); then
charge_holding=true
elif [[ $state == "charging" && $charge_idle == "true" ]] && (( threshold_end < 99 && percentage >= threshold_end )); then
charge_holding=true
fi
fi
if [[ $shell_output == "true" ]]; then
printf 'percentage\t%s\n' "${percentage}%"
if [[ $charge_holding == "true" ]]; then
printf 'state\tholding\n'
else
printf 'state\t%s\n' "$state"
fi
printf 'rate\t%s\n' "${power_rate}W"
printf 'size\t%s\n' "${capacity}Wh"
printf 'time\t%s\n' "$time_remaining"
cycles=$(cat "$power_supply_path"/BAT*/cycle_count 2>/dev/null | head -1)
[[ -n $cycles ]] && printf 'cycles\t%s\n' "$cycles"
if [[ -n $threshold_end ]]; then
if [[ -n $threshold_start && $threshold_start != $threshold_end ]]; then
printf 'threshold\t%s-%s%%\n' "$threshold_start" "$threshold_end"
else
printf 'threshold\t%s%%\n' "$threshold_end"
fi
fi
exit 0
fi
if [[ $charge_holding == "true" ]]; then
if [[ -n $threshold_start && $threshold_start != $threshold_end ]]; then
threshold_label="${threshold_start}-${threshold_end}%"
else
threshold_label="${threshold_end}%"
fi
echo "Battery ${percentage}% · Holding at ${threshold_label} · ${power_rate}W / ${capacity}Wh"
elif [[ $state == "charging" ]]; then
echo "Battery ${percentage}% · ${time_remaining} to full ·  ${power_rate}W / ${capacity}Wh"
else else
echo "Battery ${percentage}% · ${time_remaining} left ·  ${power_rate}W / ${capacity}Wh" echo "󰁹 Battery ${percentage}% · ${time_remaining} left ·  ${power_rate}W / ${capacity}Wh"
fi fi
-52
View File
@@ -1,52 +0,0 @@
#!/bin/bash
# omarchy:summary=Control a Bluetooth device
# omarchy:group=bluetooth
# omarchy:args=[pair|connect|disconnect|forget] <address>
# omarchy:examples=omarchy bluetooth device connect 00:11:22:33:44:55
set -e
usage() {
echo "Usage: omarchy-bluetooth-device [pair|connect|disconnect|forget] <address>" >&2
exit 1
}
action=${1:-}
address=${2:-}
[[ $action == "pair" || $action == "connect" || $action == "disconnect" || $action == "forget" ]] || usage
[[ $address =~ ^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$ ]] || usage
power_on() {
[[ $(timeout 2s bluetoothctl show 2>/dev/null) == *"Powered: yes"* ]] && return
# Not bluetoothctl directly: Bluetooth is turned off by an rfkill soft block,
# and BlueZ refuses to power an adapter up while one is set.
omarchy-bluetooth-power on || true
}
trust_device() {
bluetoothctl trust "$address" >/dev/null 2>&1 || true
}
case "$action" in
pair)
power_on
timeout 20s bluetoothctl pair "$address" >/dev/null 2>&1 || true
trust_device
timeout 20s bluetoothctl connect "$address" >/dev/null 2>&1 || true
;;
connect)
power_on
trust_device
timeout 20s bluetoothctl connect "$address" >/dev/null 2>&1 || true
;;
disconnect)
timeout 10s bluetoothctl disconnect "$address" >/dev/null 2>&1 || true
;;
forget)
power_on
timeout 10s bluetoothctl disconnect "$address" >/dev/null 2>&1 || true
timeout 10s bluetoothctl remove "$address" >/dev/null 2>&1 || true
;;
esac
-89
View File
@@ -1,89 +0,0 @@
#!/bin/bash
# omarchy:summary=Turn Bluetooth on or off, remembered across reboots
# omarchy:group=bluetooth
# omarchy:args=<on|off|toggle|is-on>
# BlueZ never persists an adapter's Powered property, so turning Bluetooth off
# through bluetoothctl lasts only until the next boot. The rfkill soft block does
# persist: systemd-rfkill saves every switch under /var/lib/systemd/rfkill and
# restores it early on the next boot, which is its entire job. Blocking is also
# what the kernel hands every radio at once, so a machine with two controllers
# gets both, where bluetoothctl only ever addresses the default one.
#
# So the block is the state, and BlueZ follows it: unblocking leaves AutoEnable
# at its stock default and bluetoothd powers the adapter up by itself. Every
# Omarchy path that turns Bluetooth on or off goes through here, because a plain
# `bluetoothctl power on` fails outright while the block is set.
POWER_WAIT_SECONDS=${OMARCHY_BLUETOOTH_POWER_WAIT_SECONDS:-2}
controllers() {
timeout 2s bluetoothctl list 2>/dev/null | awk '{print $2}'
}
# Any controller counts. The block is all-or-nothing across the radios, so the
# state has to be read the same way; a bare `bluetoothctl show` would report the
# default controller and miss a powered dongle sitting behind it.
powered() {
local controller
for controller in $(controllers); do
[[ $(timeout 2s bluetoothctl show "$controller" 2>/dev/null) == *"Powered: yes"* ]] && return 0
done
return 1
}
# One deadline around the whole wait rather than a fixed number of probes: every
# probe can sit on its own timeout when D-Bus is wedged, and counting probes then
# stretches a two-second wait into half a minute.
wait_powered() {
local deadline=$((SECONDS + POWER_WAIT_SECONDS))
while :; do
powered && return 0
((SECONDS < deadline)) || return 1
sleep 0.2
done
}
power_on() {
rfkill unblock bluetooth
# Usually all it takes: with AutoEnable at its default, bluetoothd powers the
# adapter up on its own once the block is gone. It will not do that for an
# adapter powered down without a block, so ask directly before giving up.
wait_powered && return 0
timeout 5s bluetoothctl power on >/dev/null 2>&1
wait_powered && return 0
echo "omarchy-bluetooth-power: adapter did not come up" >&2
return 1
}
case "${1:-}" in
on)
power_on
;;
off)
# No bluetoothctl power off to go with this: the block already drops the
# adapter to Powered: no, and it is the half that survives the reboot.
rfkill block bluetooth
;;
toggle)
if powered; then
rfkill block bluetooth
else
power_on
fi
;;
is-on)
powered
;;
*)
echo "Usage: omarchy-bluetooth-power <on|off|toggle|is-on>" >&2
exit 1
;;
esac
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
# omarchy:summary=Set the branch for Omarchy's git repository.
# omarchy:args=<master|rc|dev>
if (($# == 0)); then
echo "Usage: omarchy-branch-set [master|rc|dev]"
exit 1
else
branch="$1"
fi
if [[ $branch != "master" && $branch != "rc" && $branch != "dev" ]]; then
echo "Error: Invalid branch '$branch'. Must be one of: master, rc, dev"
exit 1
fi
git -C $OMARCHY_PATH switch $branch
+2 -2
View File
@@ -10,8 +10,8 @@ set -euo pipefail
case "${1:-}" in case "${1:-}" in
image) image)
image=$(omarchy-file-select --title "Pick PNG or SVG for About" --extensions "png svg") image=$(omarchy-menu-file "Logo image" "$HOME" "svg png")
if omarchy-transcode-ascii "$image" ~/.config/omarchy/branding/about.txt --width 54 --height 26; then if [[ -n $image ]] && omarchy-transcode-ascii "$image" ~/.config/omarchy/branding/about.txt --width 54 --height 26 --mode block; then
omarchy-launch-about >/dev/null 2>&1 omarchy-launch-about >/dev/null 2>&1
fi fi
;; ;;
Loaded 100 of 2056 files, more files were not shown because too many files have changed in this diff. Show more