Compare commits
No files matched your search
@@ -1,4 +0,0 @@
|
||||
|
||||
# Python bytecode (orchestrator)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"workspace": {
|
||||
"library": [
|
||||
"/usr/share/hypr/stubs"
|
||||
],
|
||||
"checkThirdParty": false
|
||||
},
|
||||
"diagnostics": {
|
||||
"globals": ["hl"]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
- Two spaces for indentation, no tabs
|
||||
@@ -25,9 +12,7 @@ matching guide before starting:
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
The authoritative command group list lives in `bin/omarchy` in `GROUP_DESCRIPTIONS`. Keep `GROUP_DESCRIPTIONS` updated when adding a new command prefix.
|
||||
|
||||
Common prefixes include:
|
||||
|
||||
@@ -44,77 +29,103 @@ Common prefixes include:
|
||||
- `theme-` - theme management
|
||||
- `update-` - update components
|
||||
|
||||
Do not maintain a second exhaustive prefix list here. Consult
|
||||
`GROUP_DESCRIPTIONS` when selecting or checking a command group so this
|
||||
guidance does not drift from the router.
|
||||
Other current prefixes include:
|
||||
|
||||
# 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.
|
||||
- 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.
|
||||
# Command Metadata
|
||||
|
||||
# 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
|
||||
`sudo`/`pkexec` line by whether the caller has a terminal to enter a password in, and the repo's
|
||||
own scripts follow it.
|
||||
Supported metadata keys:
|
||||
|
||||
# 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.
|
||||
- Commit messages should be succinct and describe the change being made.
|
||||
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: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
|
||||
|
||||
Use these instead of raw shell 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-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)
|
||||
|
||||
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 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.
|
||||
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.
|
||||
|
||||
# Config Structure
|
||||
|
||||
- `config/` - default configs copied to `~/.config/`
|
||||
- `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:
|
||||
|
||||
- `./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).
|
||||
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.
|
||||
|
||||
# Refresh Pattern
|
||||
|
||||
To copy a default config to user config with automatic backup:
|
||||
|
||||
```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
|
||||
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.
|
||||
This copies `~/.local/share/omarchy/config/hypr/hyprlock.conf` to `~/.config/hypr/hyprlock.conf`.
|
||||
|
||||
# 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
|
||||
```
|
||||
@@ -4,76 +4,6 @@ Omarchy is a beautiful, modern & opinionated Linux distribution by DHH.
|
||||
|
||||
Read more at [omarchy.org](https://omarchy.org).
|
||||
|
||||
## The Omarchy Manual
|
||||
|
||||
The manual lives in [`manual/`](manual/), which is its authoritative source. It's
|
||||
mirrored to [learn.omacom.io](https://learn.omacom.io/2/the-omarchy-manual), where
|
||||
its screenshots are also hosted.
|
||||
|
||||
- [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
|
||||
|
||||
Omarchy is released under the [MIT License](https://opensource.org/licenses/MIT).
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -1,79 +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). Fontconfig prefers the
|
||||
packaged font over a copy in `~/.local/share/fonts` for the same family, so a
|
||||
preview needs either the real file replaced or a `<rejectfont>` rule in
|
||||
`~/.config/fontconfig/conf.d/` pointing fontconfig away from the packaged one.
|
||||
Restart the shell afterwards — Qt reads the font database at startup, so
|
||||
`omarchy menu refresh` alone will not pick up a changed font.
|
||||
@@ -1,19 +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.
|
||||
- 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.
|
||||
@@ -1,49 +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.
|
||||
- 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)`.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
[Desktop Entry]
|
||||
Hidden=true
|
||||
@@ -0,0 +1,2 @@
|
||||
[Desktop Entry]
|
||||
Hidden=true
|
||||
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 63 KiB |
@@ -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;
|
||||
|
||||
@@ -26,48 +26,32 @@ declare -A ROUTE_IS_ALIAS
|
||||
declare -A BINARY_TO_KEY
|
||||
declare -A GROUP_DESCRIPTIONS
|
||||
|
||||
GROUP_DESCRIPTIONS[agent]="AI coding agent usage data"
|
||||
GROUP_DESCRIPTIONS[audio]="Audio input and output controls"
|
||||
GROUP_DESCRIPTIONS[bar]="Omarchy shell bar layout and settings"
|
||||
GROUP_DESCRIPTIONS[ac]="AC power detection"
|
||||
GROUP_DESCRIPTIONS[battery]="Battery status helpers"
|
||||
GROUP_DESCRIPTIONS[bluetooth]="Bluetooth device controls"
|
||||
GROUP_DESCRIPTIONS[branch]="Omarchy git branch management"
|
||||
GROUP_DESCRIPTIONS[branding]="About and screensaver branding"
|
||||
GROUP_DESCRIPTIONS[brightness]="Display and keyboard brightness"
|
||||
GROUP_DESCRIPTIONS[capture]="Screenshots and screen recording"
|
||||
GROUP_DESCRIPTIONS[channel]="Omarchy release channel management"
|
||||
GROUP_DESCRIPTIONS[clipboard]="Clipboard helpers"
|
||||
GROUP_DESCRIPTIONS[cmd]="Command and shortcut helpers"
|
||||
GROUP_DESCRIPTIONS[config]="System configuration helpers"
|
||||
GROUP_DESCRIPTIONS[debug]="Diagnostics and support logs"
|
||||
GROUP_DESCRIPTIONS[finalize]="Finalize user setup"
|
||||
GROUP_DESCRIPTIONS[default]="Default application selection"
|
||||
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[file]="File selection helpers"
|
||||
GROUP_DESCRIPTIONS[font]="Font management"
|
||||
GROUP_DESCRIPTIONS[games]="Game launchers and helpers"
|
||||
GROUP_DESCRIPTIONS[hibernation]="Hibernation setup and removal"
|
||||
GROUP_DESCRIPTIONS[hook]="User hook runner"
|
||||
GROUP_DESCRIPTIONS[hw]="Hardware detection and controls"
|
||||
GROUP_DESCRIPTIONS[hyprland]="Hyprland window, monitor, and toggle controls"
|
||||
GROUP_DESCRIPTIONS[install]="Optional software installers"
|
||||
GROUP_DESCRIPTIONS[installed]="Installed optional service checks"
|
||||
GROUP_DESCRIPTIONS[launch]="Application launchers"
|
||||
GROUP_DESCRIPTIONS[menu]="Omarchy menu commands"
|
||||
GROUP_DESCRIPTIONS[migrate]="Migration runner"
|
||||
GROUP_DESCRIPTIONS[monitor]="Monitor status helpers"
|
||||
GROUP_DESCRIPTIONS[network]="Network status helpers"
|
||||
GROUP_DESCRIPTIONS[notification]="Notification helpers"
|
||||
GROUP_DESCRIPTIONS[mise]="Mise tool wrappers"
|
||||
GROUP_DESCRIPTIONS[osd]="On-screen display status helpers"
|
||||
GROUP_DESCRIPTIONS[npx]="NPX package wrappers"
|
||||
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[power]="Power supply detection"
|
||||
GROUP_DESCRIPTIONS[powerprofiles]="Power profile management"
|
||||
GROUP_DESCRIPTIONS[refresh]="Reset config to defaults"
|
||||
GROUP_DESCRIPTIONS[reinstall]="Reinstall and reset workflows"
|
||||
@@ -75,16 +59,16 @@ GROUP_DESCRIPTIONS[reminder]="Desktop notification reminders"
|
||||
GROUP_DESCRIPTIONS[remove]="Removal workflows"
|
||||
GROUP_DESCRIPTIONS[restart]="Restart Omarchy components"
|
||||
GROUP_DESCRIPTIONS[setup]="Interactive setup wizards"
|
||||
GROUP_DESCRIPTIONS[shell]="Omarchy shell IPC helpers"
|
||||
GROUP_DESCRIPTIONS[screensaver]="Screensaver branding and animation"
|
||||
GROUP_DESCRIPTIONS[snapshot]="System snapshots"
|
||||
GROUP_DESCRIPTIONS[sudo]="Sudo configuration helpers"
|
||||
GROUP_DESCRIPTIONS[system]="System status, reboot, shutdown, logout, and lock"
|
||||
GROUP_DESCRIPTIONS[tailscale]="Tailscale helpers"
|
||||
GROUP_DESCRIPTIONS[swayosd]="SwayOSD status display helpers"
|
||||
GROUP_DESCRIPTIONS[system]="Reboot, shutdown, logout, and lock"
|
||||
GROUP_DESCRIPTIONS[theme]="Theme management"
|
||||
GROUP_DESCRIPTIONS[toggle]="Toggle Omarchy features"
|
||||
GROUP_DESCRIPTIONS[transcode]="Image and video transcoding"
|
||||
GROUP_DESCRIPTIONS[tui]="Terminal UI launchers"
|
||||
GROUP_DESCRIPTIONS[tz]="Timezone selection"
|
||||
GROUP_DESCRIPTIONS[update]="Omarchy and system updates"
|
||||
GROUP_DESCRIPTIONS[version]="Version and channel information"
|
||||
GROUP_DESCRIPTIONS[voxtype]="Voxtype dictation"
|
||||
@@ -120,31 +104,6 @@ append_pipe_value() {
|
||||
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() {
|
||||
local route="$1"
|
||||
local key="$2"
|
||||
@@ -170,6 +129,7 @@ register_command() {
|
||||
local name=""
|
||||
local summary=""
|
||||
local usage=""
|
||||
local binary=""
|
||||
local args=""
|
||||
local examples=""
|
||||
local aliases=""
|
||||
@@ -259,6 +219,7 @@ register_command() {
|
||||
fallback_name="${fallback_name//-/ }"
|
||||
fi
|
||||
|
||||
[[ -z $binary ]] && binary="$file_binary"
|
||||
[[ -z $group ]] && group="$fallback_group"
|
||||
[[ $name_seen != "true" ]] && name="$fallback_name"
|
||||
[[ -z $summary && -n $fallback_summary ]] && summary="$fallback_summary"
|
||||
@@ -268,9 +229,11 @@ register_command() {
|
||||
route+=" $name"
|
||||
fi
|
||||
|
||||
usage="$route"
|
||||
if [[ -n $args ]]; then
|
||||
usage+=" $args"
|
||||
if [[ -z $usage ]]; then
|
||||
usage="$route"
|
||||
if [[ -n $args ]]; then
|
||||
usage+=" $args"
|
||||
fi
|
||||
fi
|
||||
|
||||
[[ $requires_sudo == "true" ]] || requires_sudo="false"
|
||||
@@ -280,7 +243,7 @@ register_command() {
|
||||
COMMAND_KEYS+=("$key")
|
||||
COMMAND_ROUTE["$key"]="$route"
|
||||
COMMAND_FALLBACK_ROUTE["$key"]="$fallback_route"
|
||||
COMMAND_BINARY["$key"]="$file_binary"
|
||||
COMMAND_BINARY["$key"]="$binary"
|
||||
COMMAND_GROUP["$key"]="$group"
|
||||
COMMAND_NAME["$key"]="$name"
|
||||
COMMAND_SUMMARY["$key"]="$summary"
|
||||
@@ -292,7 +255,7 @@ register_command() {
|
||||
COMMAND_HAS_SUMMARY["$key"]="$has_summary"
|
||||
COMMAND_METADATA_ERRORS["$key"]="$metadata_errors"
|
||||
|
||||
BINARY_TO_KEY["$file_binary"]="$key"
|
||||
BINARY_TO_KEY["$binary"]="$key"
|
||||
register_route "$route" "$key"
|
||||
register_route "$fallback_route" "$key"
|
||||
|
||||
@@ -638,31 +601,25 @@ show_commands_markdown() {
|
||||
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() {
|
||||
local include_all="$1"
|
||||
local key=""
|
||||
|
||||
while IFS= read -r key; do
|
||||
[[ -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")
|
||||
}
|
||||
|
||||
@@ -731,7 +688,23 @@ show_commands_check() {
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -953,7 +926,7 @@ dispatch_fast_or_help() {
|
||||
remaining=("${args[@]:DIRECT_RESOLVED_COUNT}")
|
||||
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
|
||||
echo "Binary is missing or not executable: $DIRECT_RESOLVED_BINARY" >&2
|
||||
return 127
|
||||
@@ -964,7 +937,7 @@ dispatch_fast_or_help() {
|
||||
fi
|
||||
|
||||
key="${BINARY_TO_KEY[$DIRECT_RESOLVED_BINARY]}"
|
||||
if remaining_has_json_flag "${remaining[@]}"; then
|
||||
if [[ " ${remaining[*]} " == *" --json "* ]]; then
|
||||
show_command_json "$key"
|
||||
else
|
||||
show_command_help "$key"
|
||||
@@ -1017,8 +990,8 @@ dispatch_or_help() {
|
||||
key="$RESOLVED_KEY"
|
||||
remaining=("${args[@]:RESOLVED_COUNT}")
|
||||
|
||||
if remaining_has_help_flag "${remaining[@]}"; then
|
||||
if remaining_has_json_flag "${remaining[@]}"; then
|
||||
if (( ${#remaining[@]} > 0 )) && [[ ${remaining[0]} == "--help" || ${remaining[0]} == "-h" ]]; then
|
||||
if [[ " ${remaining[*]} " == *" --json "* ]]; then
|
||||
show_command_json "$key"
|
||||
else
|
||||
show_command_help "$key"
|
||||
|
||||
@@ -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
|
||||
@@ -1,109 +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")
|
||||
;;
|
||||
codex)
|
||||
command=(codex --approve-for-me)
|
||||
[[ -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
|
||||
@@ -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"
|
||||
@@ -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 "$*"
|
||||
@@ -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())
|
||||
@@ -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", "untrusted", "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()
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -1,53 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Configure Quickshell lock screen authentication
|
||||
# omarchy:requires-sudo=true
|
||||
# omarchy:hidden=true
|
||||
|
||||
set -e
|
||||
|
||||
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 omarchy-cmd-present fprintd-list && 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
|
||||
@@ -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
|
||||
@@ -4,10 +4,18 @@
|
||||
|
||||
wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle >/dev/null
|
||||
|
||||
if wpctl get-volume @DEFAULT_AUDIO_SOURCE@ | grep -q MUTED; then
|
||||
omarchy-brightness-keyboard-mute on
|
||||
omarchy-osd -i microphone-muted -m "Microphone muted"
|
||||
if pactl get-source-mute @DEFAULT_SOURCE@ | rg -q 'yes'; then
|
||||
led=on
|
||||
osd_message='Microphone muted'
|
||||
osd_icon='microphone-sensitivity-muted-symbolic'
|
||||
else
|
||||
omarchy-brightness-keyboard-mute off
|
||||
omarchy-osd -i microphone -m "Microphone on"
|
||||
led=off
|
||||
osd_message='Microphone on'
|
||||
osd_icon='audio-input-microphone-symbolic'
|
||||
fi
|
||||
|
||||
omarchy-brightness-keyboard-mute "$led"
|
||||
|
||||
omarchy-swayosd-client \
|
||||
--custom-message "$osd_message" \
|
||||
--custom-icon "$osd_icon"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -1,24 +1,17 @@
|
||||
#!/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
|
||||
# silently bypass the tuning rather than pick a different output.
|
||||
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")
|
||||
sinks=$(pactl -f json list sinks | jq '[.[] | select((.ports | length == 0) or ([.ports[]? | .availability != "not available"] | any))]')
|
||||
sinks_count=$(echo "$sinks" | jq '. | length')
|
||||
|
||||
if (( sinks_count == 0 )); then
|
||||
omarchy-osd -m "No audio devices found"
|
||||
omarchy-swayosd-client --custom-message "No audio devices found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
current_sink_name=$(timeout 2 pactl get-default-sink)
|
||||
current_sink_index=$(jq -r --arg name "$current_sink_name" 'map(.name) | index($name)' <<<"$sinks")
|
||||
current_sink_name=$(pactl get-default-sink)
|
||||
current_sink_index=$(echo "$sinks" | jq -r --arg name "$current_sink_name" 'map(.name) | index($name)')
|
||||
|
||||
if [[ $current_sink_index != "null" ]]; then
|
||||
next_sink_index=$(((current_sink_index + 1) % sinks_count))
|
||||
@@ -26,23 +19,28 @@ else
|
||||
next_sink_index=0
|
||||
fi
|
||||
|
||||
next_sink=$(jq -c ".[$next_sink_index]" <<<"$sinks")
|
||||
next_sink_name=$(jq -r '.name' <<<"$next_sink")
|
||||
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
|
||||
# physical sink beneath it, so read the level from whichever sink actually carries
|
||||
# it or the OSD contradicts the volume keys.
|
||||
next_sink_effective=$(omarchy-audio-output-sink "$next_sink_name")
|
||||
next_sink_volume=$(timeout 2 pactl get-sink-volume "$next_sink_effective" 2>/dev/null |
|
||||
awk 'NR == 1 {for (i = 1; i <= NF; i++) if ($i ~ /%$/) {sub("%", "", $i); print $i; exit}}')
|
||||
[[ -n $next_sink_volume ]] || next_sink_volume=$(jq -r '.volume | to_entries[0].value.value_percent | sub("%"; "") | tonumber' <<<"$next_sink")
|
||||
if [[ $(timeout 2 pactl get-sink-mute "$next_sink_effective" 2>/dev/null) == *yes ]]; then
|
||||
next_sink_is_muted=true
|
||||
else
|
||||
next_sink_is_muted=false
|
||||
next_sink=$(echo "$sinks" | jq -r ".[$next_sink_index]")
|
||||
next_sink_name=$(echo "$next_sink" | jq -r '.name')
|
||||
|
||||
next_sink_description=$(echo "$next_sink" | jq -r '.description')
|
||||
if [[ $next_sink_description == "(null)" ]] || [[ $next_sink_description == "null" ]] || [[ -z $next_sink_description ]]; then
|
||||
# For Bluetooth devices, the friendly name is on the Device entry (device.id), not the Sink entry (object.id)
|
||||
device_id=$(echo "$next_sink" | jq -r '.properties."device.id"')
|
||||
if [[ $device_id != "null" ]] && [[ -n $device_id ]]; then
|
||||
next_sink_description=$(wpctl status | grep -E "^\s*│?\s+${device_id}\." | sed -E 's/^.*[0-9]+\.\s+//' | sed -E 's/\s+\[.*$//')
|
||||
fi
|
||||
# Fall back to object.id lookup if device.id didn't yield a result
|
||||
if [[ -z $next_sink_description ]]; then
|
||||
sink_id=$(echo "$next_sink" | jq -r '.properties."object.id"')
|
||||
next_sink_description=$(wpctl status | grep -E "\s+\*?\s+${sink_id}\." | sed -E 's/^.*[0-9]+\.\s+//' | sed -E 's/\s+\[.*$//')
|
||||
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"
|
||||
elif (( next_sink_volume <= 33 )); then
|
||||
icon_state="low"
|
||||
@@ -52,8 +50,13 @@ else
|
||||
icon_state="high"
|
||||
fi
|
||||
|
||||
next_sink_volume_icon="sink-volume-${icon_state}-symbolic"
|
||||
|
||||
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
|
||||
|
||||
omarchy-osd -i "volume-${icon_state}" -m "$next_sink_description"
|
||||
omarchy-swayosd-client \
|
||||
--custom-message "$next_sink_description" \
|
||||
--custom-icon "$next_sink_volume_icon"
|
||||
@@ -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}"
|
||||
@@ -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()
|
||||
}
|
||||
'
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}'
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
}'
|
||||
@@ -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
|
||||
}'
|
||||
@@ -1,137 +1,27 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Returns a formatted battery status string with percentage and power draw/charge.
|
||||
# omarchy:args=[--shell]
|
||||
|
||||
shell_output=false
|
||||
power_supply_path="${OMARCHY_POWER_SUPPLY_PATH:-/sys/class/power_supply}"
|
||||
battery_info=$(upower -i $(upower -e | grep BAT))
|
||||
|
||||
case "${1:-}" in
|
||||
"")
|
||||
;;
|
||||
--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
|
||||
percentage=$(echo "$battery_info" | awk '/percentage/ {
|
||||
print int($2)
|
||||
exit
|
||||
}')
|
||||
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)
|
||||
[[ -z $threshold_start ]] && threshold_start=$(cat "$power_supply_path"/BAT*/charge_control_start_threshold 2>/dev/null | head -1)
|
||||
power_rate=$(echo "$battery_info" | awk '/energy-rate/ {
|
||||
rounded = sprintf("%.1f", $2)
|
||||
sub(/\.0$/, "", rounded)
|
||||
print rounded
|
||||
exit
|
||||
}')
|
||||
|
||||
ac_online=false
|
||||
for supply in "$power_supply_path"/*; do
|
||||
[[ -r $supply/type ]] || continue
|
||||
[[ $(<"$supply/type") == "Mains" ]] || continue
|
||||
[[ -r $supply/online ]] || continue
|
||||
state=$(echo "$battery_info" | awk '/state/ { print $2; exit }')
|
||||
time_remaining=$(omarchy-battery-remaining-time)
|
||||
capacity=$(omarchy-battery-capacity)
|
||||
|
||||
if [[ $(<"$supply/online") == "1" ]]; then
|
||||
ac_online=true
|
||||
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"
|
||||
if [[ $state == "charging" ]]; then
|
||||
echo " Battery ${percentage}% · ${time_remaining} to full · ${power_rate}W / ${capacity}Wh"
|
||||
else
|
||||
echo "Battery ${percentage}% · ${time_remaining} left · ${power_rate}W / ${capacity}Wh"
|
||||
echo " Battery ${percentage}% · ${time_remaining} left · ${power_rate}W / ${capacity}Wh"
|
||||
fi
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -10,8 +10,8 @@ set -euo pipefail
|
||||
|
||||
case "${1:-}" in
|
||||
image)
|
||||
image=$(omarchy-file-select --title "Pick PNG or SVG for About" --extensions "png svg")
|
||||
if omarchy-transcode-ascii "$image" ~/.config/omarchy/branding/about.txt --width 54 --height 26; then
|
||||
image=$(omarchy-menu-file "Logo image" "$HOME" "svg png")
|
||||
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
|
||||
fi
|
||||
;;
|
||||
|
||||