Compare commits
No files matched your search
@@ -1,2 +0,0 @@
|
||||
# Merges to protected branches need sign-off from an org owner.
|
||||
* @dhh @ryanrhughes
|
||||
@@ -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,32 +1,9 @@
|
||||
# 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
|
||||
- [`agents/skills/migrations.md`](agents/skills/migrations.md) - creating or changing migrations under `migrations/`
|
||||
|
||||
# Documentation Layout
|
||||
|
||||
Three documentation trees, split by genre and audience:
|
||||
|
||||
- `agents/skills/` - task procedure ("do this when doing X"), for anyone working on the codebase
|
||||
- `docs/` - reference on how the system is shaped (file layout, update pipeline, theming, shell architecture), for anyone working on the codebase; skills link here for depth
|
||||
- `manual/` - end-user documentation for using Omarchy, published; never codebase internals
|
||||
|
||||
# Style
|
||||
|
||||
- In markdown documents (`plans/`, `docs/`, `manual/`), write full lines — no hard wrapping at 80 columns; break only at structural boundaries like headings and list items
|
||||
- Two spaces for indentation, no tabs
|
||||
- Use bash 5 conditionals: use `[[ ]]` for string/file tests and `(( ))` for numeric tests
|
||||
- In `[[ ]]`, don't quote variables, but do quote string literals when comparing values (e.g., `[[ $branch == "dev" ]]`)
|
||||
- Prefer `(( ))` over numeric operators inside `[[ ]]` (e.g., `(( count < 50 ))`, not `[[ $count -lt 50 ]]`)
|
||||
- Prefer a full `if`/`else` conditional for simple two-path control flow; don't rely on `exec` or `exit` in one branch to make following statements unreachable
|
||||
- For strings/paths with spaces, quote them instead of escaping spaces with `\ ` (e.g., `"$APP_DIR/Disk Usage.desktop"`, not `$APP_DIR/Disk\ Usage.desktop`)
|
||||
- Shebangs must use `#!/bin/bash` consistently (never `#!/usr/bin/env bash`)
|
||||
- Scripts under `install/` and `migrations/` may be sourced and intentionally omit shebangs
|
||||
@@ -35,9 +12,7 @@ Three documentation trees, split by genre and audience:
|
||||
|
||||
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:
|
||||
|
||||
@@ -54,80 +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`;
|
||||
[`docs/menu.md`](docs/menu.md) covers the schema, guards, and providers.
|
||||
- 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;
|
||||
[`docs/testing.md`](docs/testing.md) covers how the suites are shaped. 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,170 +0,0 @@
|
||||
# Omarchy migrations
|
||||
|
||||
Read this before creating or changing migrations under `migrations/`.
|
||||
|
||||
Omarchy migrations are one-time repair scripts for existing installs. They are
|
||||
used when a package update needs to change state that pacman cannot safely own by
|
||||
itself.
|
||||
|
||||
## Migration model
|
||||
|
||||
Migrations live in:
|
||||
|
||||
```text
|
||||
migrations/*.sh
|
||||
```
|
||||
|
||||
They run as the current Omarchy user through `omarchy-migrate`, normally during
|
||||
`omarchy update`. A migration may touch user/session state (`~/.config`,
|
||||
`~/.local`, user systemd, browser/editor prefs, DBus/session state), and may also
|
||||
perform machine-wide repairs when needed.
|
||||
|
||||
Completion state is per-user:
|
||||
|
||||
```text
|
||||
~/.local/state/omarchy/migrations/<migration filename>
|
||||
```
|
||||
|
||||
That means every user gets a chance to run every migration. Migrations run as the
|
||||
user; privileged operations should invoke the appropriate helper or privilege
|
||||
prompt themselves. Migrations must be idempotent: if one user already applied a
|
||||
machine-wide repair, the same migration running for another user should detect
|
||||
that and no-op.
|
||||
|
||||
## When migrations run
|
||||
|
||||
### During `omarchy update`
|
||||
|
||||
`omarchy update` is the normal update path. It runs package updates, then:
|
||||
|
||||
```bash
|
||||
omarchy-migrate
|
||||
omarchy-hook post-update
|
||||
```
|
||||
|
||||
`omarchy-migrate` waits for any active pacman transaction to finish, then runs
|
||||
all pending migrations for the current user in the visible update terminal.
|
||||
|
||||
### At login
|
||||
|
||||
Every graphical login starts `omarchy-migrate-notify.service` after
|
||||
`graphical-session.target`. The notifier checks:
|
||||
|
||||
```bash
|
||||
omarchy-migrate --pending
|
||||
```
|
||||
|
||||
It stays silent while `omarchy update` holds its lock, since that update applies
|
||||
the pending migrations itself.
|
||||
|
||||
If that user has pending migrations, it shows a notification that opens a
|
||||
terminal for:
|
||||
|
||||
```bash
|
||||
omarchy-migrate
|
||||
```
|
||||
|
||||
The notifier never runs migrations silently in the background.
|
||||
|
||||
This is what covers users who did not run the update themselves: someone who
|
||||
bypassed the pacman guard with `sudo env OMARCHY_ALLOW_DIRECT_PACMAN=1 pacman
|
||||
-Syu`, and any second user on the machine, whose migration markers are per-user
|
||||
and therefore still missing after another user updated.
|
||||
|
||||
Login is the only trigger on purpose. Watching the packaged migration directory
|
||||
also fires during a normal `omarchy update`, which prompts for migrations that
|
||||
`omarchy-migrate` is about to run in the visible update terminal.
|
||||
|
||||
### Manually
|
||||
|
||||
Users can safely run:
|
||||
|
||||
```bash
|
||||
omarchy-migrate
|
||||
```
|
||||
|
||||
at any time. Already-completed migrations are skipped.
|
||||
|
||||
## Inspecting pending migrations
|
||||
|
||||
Use:
|
||||
|
||||
```bash
|
||||
omarchy-migrate --pending
|
||||
```
|
||||
|
||||
Exit behavior:
|
||||
|
||||
- `0` — one or more migrations are pending
|
||||
- non-zero — no migrations are pending
|
||||
|
||||
Output is one pending migration per line:
|
||||
|
||||
```text
|
||||
1781158082.sh
|
||||
```
|
||||
|
||||
## Creating a migration
|
||||
|
||||
Use the helper:
|
||||
|
||||
```bash
|
||||
omarchy-dev-add-migration --no-edit
|
||||
```
|
||||
|
||||
This creates:
|
||||
|
||||
```text
|
||||
migrations/<unix timestamp>.sh
|
||||
```
|
||||
|
||||
New migration format:
|
||||
|
||||
- File permissions must be `0644` (`-rw-r--r--`). Migration runners execute them
|
||||
with `bash -euo pipefail`, not through executable bits.
|
||||
- No shebang line.
|
||||
- Start with an `echo` describing what the migration does.
|
||||
- Use `$OMARCHY_PATH` to reference the Omarchy directory.
|
||||
- Be idempotent. Check existing state before changing it.
|
||||
- Migrations are strictly ordered and synchronous. A migration that cannot finish must exit non-zero, remain pending, and stop the queue; never mark later migrations complete against state an earlier migration has not established.
|
||||
- Use helper commands such as `omarchy-cmd-present`, `omarchy-cmd-missing`,
|
||||
`omarchy-pkg-add`, `omarchy-pkg-drop`, `omarchy-pkg-present`, and
|
||||
`omarchy-pkg-missing` when appropriate.
|
||||
- Never restart the Omarchy shell. `omarchy update` restarts it unconditionally
|
||||
after migrations run, and the login-time shell already runs current code and
|
||||
hot-reloads `shell.json` edits.
|
||||
- Raw `pacman`, `command -v`, and direct config edits are acceptable when
|
||||
needed for one-off repair work.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
echo "Relink Neovim theme to Omarchy current state"
|
||||
|
||||
theme_link="$HOME/.config/nvim/lua/plugins/theme.lua"
|
||||
current_relative_target="../../../../.local/state/omarchy/current/theme/neovim.lua"
|
||||
|
||||
[[ -L $theme_link ]] || exit 0
|
||||
ln -sfn "$current_relative_target" "$theme_link"
|
||||
```
|
||||
|
||||
## Testing migrations
|
||||
|
||||
Run a migration against a temporary home when possible:
|
||||
|
||||
```bash
|
||||
HOME=$(mktemp -d) bash -euo pipefail migrations/<timestamp>.sh
|
||||
```
|
||||
|
||||
To rerun a migration locally, remove its marker and run the migrator:
|
||||
|
||||
```bash
|
||||
rm ~/.local/state/omarchy/migrations/<migration>.sh
|
||||
omarchy-migrate
|
||||
```
|
||||
|
||||
Omarchy 4.0 is upgraded through `bin/omarchy-upgrade-to-quattro`, not through the
|
||||
normal migration runner. Do not add compatibility migrations for old installer
|
||||
layouts; put pre-4 package-layout transition work in the upgrade command instead.
|
||||
|
||||
Clearing a privileged file that a retired installer left on disk is the exception, and belongs in a migration whether or not that installer was part of a package layout transition. The upgrade command only runs on a machine still making the 3 to 4 crossing, so anything put there never reaches an install that crossed already, and it never runs at all for an installer that was retired on its own — while the file the installer wrote is still sitting on those machines. The upgrade command finishes by running `omarchy-migrate` (`run_post_upgrade_migrations`), so one migration reaches every population; a copy in the upgrade command would only be a second copy of the same predicate to keep correct. Such a migration must name the defect it clears and match what the old installer actually produced before deleting it. Leave safe administrator-authored files alone; if one still contains the vulnerable privileged action, preserve it under an inactive name rather than discarding custom content or leaving the action executable. A user config that depends on the same retired compatibility path may be repaired in that migration when doing so eliminates an overlapping migration, but only by matching and replacing the exact legacy path while preserving the rest of the file.
|
||||
@@ -1,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,50 +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[ascii]="Text drawn as ASCII art"
|
||||
GROUP_DESCRIPTIONS[audio]="Audio input and output controls"
|
||||
GROUP_DESCRIPTIONS[bar]="Omarchy shell bar layout and settings"
|
||||
GROUP_DESCRIPTIONS[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[crash]="Crash notification controls"
|
||||
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"
|
||||
@@ -77,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"
|
||||
@@ -122,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"
|
||||
@@ -172,6 +129,7 @@ register_command() {
|
||||
local name=""
|
||||
local summary=""
|
||||
local usage=""
|
||||
local binary=""
|
||||
local args=""
|
||||
local examples=""
|
||||
local aliases=""
|
||||
@@ -261,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"
|
||||
@@ -270,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"
|
||||
@@ -282,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"
|
||||
@@ -294,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"
|
||||
|
||||
@@ -640,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")
|
||||
}
|
||||
|
||||
@@ -733,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() {
|
||||
@@ -955,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
|
||||
@@ -966,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"
|
||||
@@ -1019,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,130 +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, Ori and Dim have none to
|
||||
# skip.
|
||||
case "$agent" in
|
||||
opencode)
|
||||
command=(opencode --auto)
|
||||
[[ -n ${prompt:-} ]] && command+=(--prompt "$prompt")
|
||||
;;
|
||||
agy)
|
||||
command=(agy --dangerously-skip-permissions)
|
||||
[[ -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")
|
||||
;;
|
||||
dim)
|
||||
# `dim exec` is the one-shot prompt form; interactive dim is the bare TUI.
|
||||
if [[ -n ${prompt:-} ]]; then
|
||||
command=(dim exec "$prompt")
|
||||
else
|
||||
command=(dim)
|
||||
fi
|
||||
;;
|
||||
grok)
|
||||
command=(grok --permission-mode bypassPermissions)
|
||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
||||
;;
|
||||
kimi)
|
||||
# --prompt is kimi's one-shot form, like `crush run`; --auto spares its questions.
|
||||
command=(kimi --auto)
|
||||
[[ -n ${prompt:-} ]] && command+=(--prompt "$prompt")
|
||||
;;
|
||||
codex)
|
||||
command=(codex --approve-for-me)
|
||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
||||
;;
|
||||
omp)
|
||||
command=(omp --auto-approve)
|
||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
||||
;;
|
||||
ori)
|
||||
# Ori is a harness launcher, and `ori code` is the agent it runs itself.
|
||||
# A prompt alone means one headless turn there, printed after the turn ends,
|
||||
# so --interactive is what seeds the session with it and keeps the window.
|
||||
command=(ori code)
|
||||
[[ -n ${prompt:-} ]] && command+=(--interactive --prompt "$prompt")
|
||||
;;
|
||||
pi)
|
||||
command=(pi)
|
||||
[[ -n ${prompt:-} ]] && command+=("$prompt")
|
||||
;;
|
||||
*)
|
||||
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", "on-request", "app-server"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
env=ENV,
|
||||
)
|
||||
except Exception as exc:
|
||||
result["usageStatusText"] = "Codex unavailable"
|
||||
result["authHelpText"] = str(exc)
|
||||
return result
|
||||
|
||||
try:
|
||||
rpc_request(proc, 1, "initialize", {"clientInfo": {"name": "omarchy-agent-usage", "version": "1"}}, timeout=8)
|
||||
proc.stdin.write(json.dumps({"method": "initialized", "params": {}}) + "\n")
|
||||
proc.stdin.flush()
|
||||
account_msg = rpc_request(proc, 2, "account/read", timeout=4)
|
||||
limits_msg = rpc_request(proc, 3, "account/rateLimits/read", timeout=4)
|
||||
|
||||
account = (account_msg.get("result") or {}).get("account") or {}
|
||||
limits = (limits_msg.get("result") or {}).get("rateLimits") or {}
|
||||
plan = limits.get("planType") or account.get("planType") or account.get("type") or ""
|
||||
result["tierLabel"] = str(plan) if plan else ""
|
||||
|
||||
for window in (limits.get("primary"), limits.get("secondary")):
|
||||
entry = limit_window(window)
|
||||
if entry:
|
||||
result["limits"].append(entry)
|
||||
except Exception as exc:
|
||||
result["usageStatusText"] = "Codex limits unavailable"
|
||||
result["authHelpText"] = str(exc)
|
||||
finally:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=1)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
# --force rescans everything and rewrites the cache. --limits-only is kept
|
||||
# for CLI compatibility with the panel's refreshLimits() call: only the
|
||||
# limits probe must be fresh, so it may reuse a scan for far longer than a
|
||||
# normal run, whose short window exists purely to dedup concurrent
|
||||
# collector runs.
|
||||
parser.add_argument("--force", action="store_true")
|
||||
parser.add_argument("--limits-only", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
max_age = 0 if args.force else (LIMITS_ONLY_REUSE_SECONDS if args.limits_only else SCAN_REUSE_SECONDS)
|
||||
stats = cached_local_stats(max_age)
|
||||
rpc = fetch_codex_rpc()
|
||||
|
||||
record = {
|
||||
"schemaVersion": 1,
|
||||
"id": AGENT_ID,
|
||||
"name": AGENT_NAME,
|
||||
"updatedAt": datetime.now(timezone.utc).isoformat(),
|
||||
"ready": True,
|
||||
"hasLocalStats": True,
|
||||
}
|
||||
record.update(stats)
|
||||
record.update(rpc)
|
||||
print(json.dumps(record, separators=(",", ":")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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 (( $# > 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
|
||||