Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3f35c57fb |
No files matched your search
@@ -1,4 +0,0 @@
|
|||||||
|
|
||||||
# Python bytecode (orchestrator)
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"workspace": {
|
|
||||||
"library": [
|
|
||||||
"/usr/share/hypr/stubs"
|
|
||||||
],
|
|
||||||
"checkThirdParty": false
|
|
||||||
},
|
|
||||||
"diagnostics": {
|
|
||||||
"globals": ["hl"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,3 @@
|
|||||||
# Task Guides
|
|
||||||
|
|
||||||
Deeper instructions for specific kinds of work live in `agents/skills/`. Read the
|
|
||||||
matching guide before starting:
|
|
||||||
|
|
||||||
- [`agents/skills/command-metadata.md`](agents/skills/command-metadata.md) - adding or changing commands in `bin/`
|
|
||||||
- [`agents/skills/install-scripts.md`](agents/skills/install-scripts.md) - working under `install/` or on system/user setup commands
|
|
||||||
- [`agents/skills/shell-dev.md`](agents/skills/shell-dev.md) - editing the Quickshell desktop under `shell/`
|
|
||||||
- [`agents/skills/icon-font.md`](agents/skills/icon-font.md) - adding branded glyphs to `default/fonts/omarchy/omarchy.ttf`
|
|
||||||
- [`agents/skills/acceptance-tests.md`](agents/skills/acceptance-tests.md) - writing or running graphical acceptance tests under `test/acceptance.d/`
|
|
||||||
- [`agents/skills/visual-verification.md`](agents/skills/visual-verification.md) - verifying any change with a visual effect in the running UI
|
|
||||||
- [`docs/migrations.md`](docs/migrations.md) - creating or changing migrations under `migrations/`
|
|
||||||
|
|
||||||
# Style
|
# Style
|
||||||
|
|
||||||
- Two spaces for indentation, no tabs
|
- Two spaces for indentation, no tabs
|
||||||
@@ -25,9 +12,7 @@ matching guide before starting:
|
|||||||
|
|
||||||
All commands start with `omarchy-`. Prefixes indicate purpose.
|
All commands start with `omarchy-`. Prefixes indicate purpose.
|
||||||
|
|
||||||
The authoritative list of user-facing command groups lives in `bin/omarchy` in `GROUP_DESCRIPTIONS`. Keep `GROUP_DESCRIPTIONS` updated when adding a new command prefix users are meant to browse to.
|
The authoritative command group list lives in `bin/omarchy` in `GROUP_DESCRIPTIONS`. Keep `GROUP_DESCRIPTIONS` updated when adding a new command prefix.
|
||||||
|
|
||||||
A group whose commands are all `# omarchy:hidden=true` gets no entry. That table drives the top-level group listing on its own, so an entry there advertises the group even when every command in it is hidden. `apply-` and `provision-` are deliberately absent for that reason; both still route, and `omarchy <group>` still prints a group header without one.
|
|
||||||
|
|
||||||
Common prefixes include:
|
Common prefixes include:
|
||||||
|
|
||||||
@@ -44,77 +29,103 @@ Common prefixes include:
|
|||||||
- `theme-` - theme management
|
- `theme-` - theme management
|
||||||
- `update-` - update components
|
- `update-` - update components
|
||||||
|
|
||||||
Do not maintain a second exhaustive prefix list here. Consult
|
Other current prefixes include:
|
||||||
`GROUP_DESCRIPTIONS` when selecting or checking a command group so this
|
|
||||||
guidance does not drift from the router.
|
|
||||||
|
|
||||||
# Runtime Environment
|
- `ac-`, `audio-`, `battery-`, `branch-`, `brightness-`, `channel-`, `config-`, `debug-`, `dev-`, `drive-`, `first-`, `font-`, `haptic-`, `hibernation-`, `hook-`, `hyprland-`, `menu-`, `migrate-`, `notification-`, `npx-`, `plymouth-`, `powerprofiles-`, `reinstall-`, `remove-`, `screensaver-`, `show-`, `snapshot-`, `state-`, `sudo-`, `swayosd-`, `system-`, `transcode-`, `tui-`, `tz-`, `upload-`, `version-`, `voxtype-`, `webapp-`, `wifi-`, `windows-`
|
||||||
|
|
||||||
- `$OMARCHY_PATH` is set at the top level by the uwsm session environment and is always available to Omarchy runtime code.
|
# Command Metadata
|
||||||
- Commands in `bin/` and Quickshell QML should rely on `$OMARCHY_PATH` / `Quickshell.env("OMARCHY_PATH")`; do not derive fallback paths from `HOME`, `Quickshell.shellDir`, or re-export/default `OMARCHY_PATH` manually.
|
|
||||||
|
|
||||||
# Privileged Commands
|
Commands in `bin/` can declare CLI metadata in comments near the top of the file. `bin/omarchy` scans the first 80 lines, and tests expect command metadata to remain valid.
|
||||||
|
|
||||||
- Follow the "Privilege Escalation" section of `default/agents/skills/omarchy/SKILL.md`. It draws the
|
Supported metadata keys:
|
||||||
`sudo`/`pkexec` line by whether the caller has a terminal to enter a password in, and the repo's
|
|
||||||
own scripts follow it.
|
|
||||||
|
|
||||||
# Git
|
- `# omarchy:summary=...` - short help text
|
||||||
|
- `# omarchy:group=...` - command group when it differs from the filename-derived prefix
|
||||||
|
- `# omarchy:name=...` - command name within the group
|
||||||
|
- `# omarchy:args=...` - usage arguments
|
||||||
|
- `# omarchy:examples=...` - examples separated with ` | `
|
||||||
|
- `# omarchy:alias=...` / `# omarchy:aliases=...` - alternate routes
|
||||||
|
- `# omarchy:hidden=true` - hide from default command listings
|
||||||
|
- `# omarchy:requires-sudo=true` - mark commands that require sudo
|
||||||
|
|
||||||
- Commits should be atomic: include only one coherent change or fix, and do not mix unrelated work.
|
Prefer explicit metadata for user-facing commands. Keep routes consistent with the filename unless there is a deliberate alias or compatibility route.
|
||||||
- Commit messages should be succinct and describe the change being made.
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# omarchy:summary=Take a screenshot
|
||||||
|
# omarchy:group=capture
|
||||||
|
# omarchy:args=[smart|region|windows|fullscreen] [slurp|copy]
|
||||||
|
# omarchy:examples=omarchy screenshot | omarchy capture screenshot region
|
||||||
|
# omarchy:aliases=omarchy screenshot
|
||||||
|
```
|
||||||
|
|
||||||
|
# Install Scripts
|
||||||
|
|
||||||
|
Install entry points (`install.sh`, `boot.sh`) use `#!/bin/bash`. Many scripts under `install/` are sourced via `run_logged` and intentionally do not have shebangs.
|
||||||
|
|
||||||
|
Install stage files follow this pattern:
|
||||||
|
|
||||||
|
- `install/*/all.sh` lists scripts in execution order
|
||||||
|
- leaf scripts are sourced by `run_logged $OMARCHY_INSTALL/path/to/script.sh`
|
||||||
|
- avoid `exit` in sourced install scripts unless intentionally aborting the install
|
||||||
|
- use `$OMARCHY_INSTALL` and `$OMARCHY_PATH` instead of hard-coded Omarchy paths
|
||||||
|
- keep hardware-specific logic under `install/config/hardware/`
|
||||||
|
- prefer helper commands for package and command checks where available
|
||||||
|
|
||||||
|
Raw `command -v`, `pacman`, and `pacman-key` are acceptable in bootstrap/preflight/package-helper contexts where the helper commands may not be available yet or where direct package-manager behavior is the point of the script.
|
||||||
|
|
||||||
# Helper Commands
|
# Helper Commands
|
||||||
|
|
||||||
Use these instead of raw shell commands:
|
Use these instead of raw shell commands:
|
||||||
|
|
||||||
- `omarchy-cmd-missing` / `omarchy-cmd-present` - check for commands
|
- `omarchy-cmd-missing` / `omarchy-cmd-present` - check for commands
|
||||||
- `omarchy-pkg-missing` / `omarchy-pkg-present` - check for packages (don't use these if you can just use `omarchy-pkg-add`/`omarchy-pkg-drop`)
|
- `omarchy-pkg-missing` / `omarchy-pkg-present` - check for packages
|
||||||
- `omarchy-pkg-add` - install packages (handles both pacman and AUR)
|
- `omarchy-pkg-add` - install packages (handles both pacman and AUR)
|
||||||
- `omarchy-pkg-drop` - remove packages; use this instead of raw `pacman -R*`
|
|
||||||
- `omarchy-notification-send` - send desktop notifications; do not call `notify-send` directly
|
|
||||||
- `omarchy-hw-asus-rog` - detect ASUS ROG hardware (and similar `hw-*` commands)
|
- `omarchy-hw-asus-rog` - detect ASUS ROG hardware (and similar `hw-*` commands)
|
||||||
|
|
||||||
Commands installed by Omarchy's default package set are runtime invariants. Invoke them directly; do not add defensive `omarchy-cmd-present` / `omarchy-cmd-missing` checks around them. Use command-presence helpers only for genuinely optional dependencies or code that can run before the default package set is installed.
|
Exceptions are allowed for bootstrap, preflight, migration, and package-helper scripts where the helper may not be available yet, where the helper itself is being implemented, or where direct package-manager behavior is required.
|
||||||
|
|
||||||
Exceptions are allowed for migration and package-helper scripts where the helper may not be available yet, where the helper itself is being implemented, or where direct package-manager behavior is required.
|
|
||||||
|
|
||||||
# Menu
|
|
||||||
|
|
||||||
- The menu definition lives in `default/omarchy/omarchy-menu.jsonc`.
|
|
||||||
- Do not add `aliases` to new menu entries. Aliases are reserved for
|
|
||||||
established alternate names users already type, kept for compatibility.
|
|
||||||
|
|
||||||
# Config Structure
|
# Config Structure
|
||||||
|
|
||||||
- `config/` - default configs copied to `~/.config/`
|
- `config/` - default configs copied to `~/.config/`
|
||||||
- `default/themed/*.tpl` - templates with `{{ variable }}` placeholders for theme colors
|
- `default/themed/*.tpl` - templates with `{{ variable }}` placeholders for theme colors
|
||||||
- `themes/*/colors.toml` - theme color definitions (accent, background, foreground, red/green/yellow/blue/magenta/cyan and bright_* variants)
|
- `themes/*/colors.toml` - theme color definitions (accent, background, foreground, color0-15)
|
||||||
|
|
||||||
# Tests
|
# Visual Changes
|
||||||
|
|
||||||
Run focused automated tests for the area you changed. Current test entry points:
|
When making visual changes, such as Waybar styles or desktop appearance, always take and analyze a screenshot after applying the change to verify the result. Use `omarchy capture screenshot fullscreen save` for fullscreen screenshots.
|
||||||
|
|
||||||
- `./test/all` - aggregate runner for CLI and shell tests; it intentionally does not run graphical acceptance tests
|
|
||||||
- `./test/cli` - CLI routing, command metadata, theme helpers, and safe dispatch coverage
|
|
||||||
- `./test/shell` - all Omarchy shell tests under `test/shell.d/`
|
|
||||||
|
|
||||||
New Omarchy shell tests should live in `test/shell.d/*-test.sh` so `./test/shell` picks them up automatically. Source `test/shell.d/base-test.sh` for shared root-path discovery, assertions, and Node test helpers.
|
|
||||||
|
|
||||||
The graphical acceptance suite runs in a disposable VM, not in the active
|
|
||||||
development session; see [`agents/skills/acceptance-tests.md`](agents/skills/acceptance-tests.md).
|
|
||||||
|
|
||||||
Visual changes must be verified in the running UI in addition to automated
|
|
||||||
tests; follow [`agents/skills/visual-verification.md`](agents/skills/visual-verification.md).
|
|
||||||
|
|
||||||
# Refresh Pattern
|
# Refresh Pattern
|
||||||
|
|
||||||
To copy a default config to user config with automatic backup:
|
To copy a default config to user config with automatic backup:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
omarchy-refresh-config hypr/hyprland.lua
|
omarchy-refresh-config hypr/hyprlock.conf
|
||||||
```
|
```
|
||||||
|
|
||||||
This copies `$OMARCHY_PATH/config/hypr/hyprland.lua` to `~/.config/hypr/hyprland.lua`. The argument
|
This copies `~/.local/share/omarchy/config/hypr/hyprlock.conf` to `~/.config/hypr/hyprlock.conf`.
|
||||||
is interpolated into both paths and only checked with `[[ -e ]]`, so pass a plain relative path: a
|
|
||||||
name containing `..` resolves and copies, landing outside `~/.config` rather than being rejected.
|
# Migrations
|
||||||
|
|
||||||
|
To create a new migration, run `omarchy-dev-add-migration --no-edit`. This creates a migration file named after the unix timestamp of the last commit.
|
||||||
|
|
||||||
|
New migration format:
|
||||||
|
- File permissions must be `0644` (`-rw-r--r--`); migrations are sourced, not executed directly
|
||||||
|
- No shebang line
|
||||||
|
- Start with an `echo` describing what the migration does
|
||||||
|
- Use `$OMARCHY_PATH` to reference the omarchy directory
|
||||||
|
- Prefer helper commands such as `omarchy-cmd-present`, `omarchy-cmd-missing`, `omarchy-pkg-present`, and `omarchy-pkg-missing`
|
||||||
|
|
||||||
|
Some older migrations predate these rules. Do not copy older migrations that start with shebangs, omit the leading `echo`, or hard-code `~/.local/share/omarchy`.
|
||||||
|
|
||||||
|
Migrations may use raw `pacman`, `command -v`, or direct config edits when needed for historical compatibility or one-off repair work.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```bash
|
||||||
|
echo "Disable fingerprint in hyprlock if fingerprint auth is not configured"
|
||||||
|
|
||||||
|
if omarchy-cmd-missing fprintd-list || ! fprintd-list "$USER" 2>/dev/null | grep -q "finger"; then
|
||||||
|
sed -i 's/fingerprint:enabled = .*/fingerprint:enabled = false/' ~/.config/hypr/hyprlock.conf
|
||||||
|
fi
|
||||||
|
```
|
||||||
@@ -4,76 +4,6 @@ Omarchy is a beautiful, modern & opinionated Linux distribution by DHH.
|
|||||||
|
|
||||||
Read more at [omarchy.org](https://omarchy.org).
|
Read more at [omarchy.org](https://omarchy.org).
|
||||||
|
|
||||||
## The Omarchy Manual
|
|
||||||
|
|
||||||
The manual lives in [`manual/`](manual/), which is its authoritative source. It's
|
|
||||||
mirrored to [learn.omacom.io](https://learn.omacom.io/2/the-omarchy-manual), where
|
|
||||||
its screenshots are also hosted.
|
|
||||||
|
|
||||||
- [Welcome to Omarchy!](manual/01-welcome-to-omarchy.md)
|
|
||||||
|
|
||||||
**The Basics**
|
|
||||||
|
|
||||||
- [Getting Started](manual/02-getting-started.md)
|
|
||||||
- [Coming From Mac or Windows](manual/03-coming-from-mac-or-windows.md)
|
|
||||||
- [Navigation](manual/04-navigation.md)
|
|
||||||
- [The top bar](manual/05-the-top-bar.md)
|
|
||||||
- [Themes](manual/06-themes.md)
|
|
||||||
- [Hotkeys](manual/07-hotkeys.md)
|
|
||||||
- [Unified Clipboard & History](manual/08-unified-clipboard-history.md)
|
|
||||||
- [Reminders](manual/09-reminders.md)
|
|
||||||
- [Notices](manual/10-notices.md)
|
|
||||||
- [Text Extraction & Dictation](manual/11-text-extraction-dictation.md)
|
|
||||||
- [Screenshots & Recording](manual/12-screenshots-recording.md)
|
|
||||||
- [Toggles, idle & screensaver](manual/13-toggles-idle-screensaver.md)
|
|
||||||
- [Omarchy CLI](manual/14-omarchy-cli.md)
|
|
||||||
|
|
||||||
**The Applications**
|
|
||||||
|
|
||||||
- [Terminal](manual/15-terminal.md)
|
|
||||||
- [Neovim](manual/16-neovim.md)
|
|
||||||
- [AI](manual/17-ai.md)
|
|
||||||
- [Development Tools](manual/18-development-tools.md)
|
|
||||||
- [Shell Tools](manual/19-shell-tools.md)
|
|
||||||
- [Shell Functions](manual/20-shell-functions.md)
|
|
||||||
- [TUIs](manual/21-tuis.md)
|
|
||||||
- [GUIs](manual/22-guis.md)
|
|
||||||
- [Browsers](manual/23-browsers.md)
|
|
||||||
- [Commercial apps/services](manual/24-commercial-apps-services.md)
|
|
||||||
- [Web Apps](manual/25-web-apps.md)
|
|
||||||
- [Gaming](manual/26-gaming.md)
|
|
||||||
- [Filling out PDFs](manual/27-filling-out-pdfs.md)
|
|
||||||
- [Windows VM](manual/28-windows-vm.md)
|
|
||||||
- [Other Packages](manual/29-other-packages.md)
|
|
||||||
|
|
||||||
**Configuration**
|
|
||||||
|
|
||||||
- [Updates](manual/30-updates.md)
|
|
||||||
- [Dotfiles](manual/31-dotfiles.md)
|
|
||||||
- [Shell plugins](manual/32-shell-plugins.md)
|
|
||||||
- [Monitors](manual/33-monitors.md)
|
|
||||||
- [Keyboard, Mouse, Trackpad](manual/34-keyboard-mouse-trackpad.md)
|
|
||||||
- [Networking](manual/35-networking.md)
|
|
||||||
- [System sleep](manual/36-system-sleep.md)
|
|
||||||
- [Hardware authentication](manual/37-hardware-authentication.md)
|
|
||||||
- [Fonts](manual/38-fonts.md)
|
|
||||||
- [Backgrounds](manual/39-backgrounds.md)
|
|
||||||
- [Prompt](manual/40-prompt.md)
|
|
||||||
- [Branding](manual/41-branding.md)
|
|
||||||
- [Common tweaks](manual/42-common-tweaks.md)
|
|
||||||
- [Making your own theme](manual/43-making-your-own-theme.md)
|
|
||||||
|
|
||||||
**The Rest**
|
|
||||||
|
|
||||||
- [Mac support](manual/44-mac-support.md)
|
|
||||||
- [Troubleshooting](manual/45-troubleshooting.md)
|
|
||||||
- [FAQ](manual/46-faq.md)
|
|
||||||
- [System snapshots](manual/47-system-snapshots.md)
|
|
||||||
- [Security](manual/48-security.md)
|
|
||||||
- [Omarchy on...](manual/49-omarchy-on.md)
|
|
||||||
- [Dual Boot Install](manual/50-dual-boot-install.md)
|
|
||||||
- [Unattended Installs](manual/51-unattended-installs.md)
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Omarchy is released under the [MIT License](https://opensource.org/licenses/MIT).
|
Omarchy is released under the [MIT License](https://opensource.org/licenses/MIT).
|
||||||
@@ -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,75 +0,0 @@
|
|||||||
# Omarchy Icon Font
|
|
||||||
|
|
||||||
Read this before adding a branded glyph to `default/fonts/omarchy/omarchy.ttf`.
|
|
||||||
|
|
||||||
The Omarchy icon font is a small private-use font carrying the marks Nerd
|
|
||||||
Fonts does not have: the Omarchy logo and the agent and app brand marks. The
|
|
||||||
menu draws one by naming the font on an entry:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
"setup.default.agent.grok": {"icon":"","iconFont":"omarchy","label":"Grok", ...}
|
|
||||||
```
|
|
||||||
|
|
||||||
Without `iconFont`, an entry's `icon` is drawn in the menu font, so reach for
|
|
||||||
this font only when a Nerd Font glyph would misrepresent the thing. A generic
|
|
||||||
robot for four different AI apps is the case that justifies a real mark; a
|
|
||||||
folder or a microphone is not.
|
|
||||||
|
|
||||||
`default/fonts/omarchy/README.md` lists every glyph with the URL its artwork
|
|
||||||
came from. Keep that list accurate — it is the only record of provenance.
|
|
||||||
|
|
||||||
## Adding a glyph
|
|
||||||
|
|
||||||
`omarchy dev font` does the work:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
omarchy dev font list
|
|
||||||
omarchy dev font add ollama https://simpleicons.org/icons/ollama.svg
|
|
||||||
```
|
|
||||||
|
|
||||||
`add` fetches the SVG, scales it into the same 64..960 box the existing marks
|
|
||||||
use so it lands at their optical size, appends it at the next free private-use
|
|
||||||
codepoint, and adds a line to the font README. It prints the codepoint and the
|
|
||||||
glyph itself.
|
|
||||||
|
|
||||||
The source must be a **monochrome SVG with a single `<path>`**, because the
|
|
||||||
menu recolors the glyph with the active theme's foreground and selection
|
|
||||||
colors. Brand icon sets such as <https://simpleicons.org> publish exactly that
|
|
||||||
shape. App favicons usually do not: they are multi-color, carry a container
|
|
||||||
tile, or split across several paths. Prefer the official mark when it is
|
|
||||||
published as flat monochrome art, and fall back to an icon set's redraw when it
|
|
||||||
is not.
|
|
||||||
|
|
||||||
Two-tone marks are a trap. Every path becomes solid foreground, so a logo whose
|
|
||||||
meaning depends on lighter and darker halves turns into an unreadable blob.
|
|
||||||
Pick a source whose silhouette alone reads.
|
|
||||||
|
|
||||||
## After adding
|
|
||||||
|
|
||||||
The command prints these, and all of them matter:
|
|
||||||
|
|
||||||
- Point the menu entry at the new codepoint with `"iconFont":"omarchy"`.
|
|
||||||
- Bump the charset range asserted in `test/shell.d/menu-test.sh`; that test
|
|
||||||
pins the font's coverage and fails until it matches.
|
|
||||||
- Check the README line the command added, and give it a proper display name
|
|
||||||
with `--label` if the glyph name is not the brand's name.
|
|
||||||
|
|
||||||
The font is package-owned: `omarchy-settings` installs it to
|
|
||||||
`/usr/share/fonts/omarchy/omarchy.ttf`, so a new glyph reaches the desktop
|
|
||||||
through a settings release, not through `omarchy update`. Between the merge and
|
|
||||||
that release, a pulled checkout renders the new entry with no icon.
|
|
||||||
|
|
||||||
## Verifying
|
|
||||||
|
|
||||||
Render the whole font and look at it, which catches inverted contours and
|
|
||||||
filled counters that a glyph list cannot show:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 -c "open('/tmp/row.txt','w').write(' '.join(chr(c) for c in range(0xE900, 0xE910)))"
|
|
||||||
magick -background white -fill black -font default/fonts/omarchy/omarchy.ttf \
|
|
||||||
-pointsize 110 label:@/tmp/row.txt /tmp/font-row.png
|
|
||||||
```
|
|
||||||
|
|
||||||
Then confirm it in the running menu per [`visual-verification.md`](visual-verification.md). Avoid leaving two fonts with the `omarchy` family registered: Qt can use an old copy in `~/.local/share/fonts` even when `fc-match omarchy` reports the packaged font. For a preview, either replace the packaged file in the disposable VM or temporarily exclude it with a `<rejectfont>` rule in `~/.config/fontconfig/conf.d/` before loading the candidate.
|
|
||||||
|
|
||||||
Refresh the font cache and restart the shell afterwards — Qt reads the font database at startup, so `omarchy menu refresh` alone will not pick up a changed font. Remove temporary fonts and rules after verification.
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Install Scripts
|
|
||||||
|
|
||||||
Read this before working under `install/` or on the system/user setup commands.
|
|
||||||
|
|
||||||
The ISO owns installation orchestration. This repo ships target-side setup
|
|
||||||
commands and reusable setup leaves:
|
|
||||||
|
|
||||||
- `bin/omarchy-apply-system` runs root-owned system setup during ISO finalization.
|
|
||||||
- `bin/omarchy-apply-hardware` runs idempotent hardware-specific setup and is called by `omarchy-apply-system`.
|
|
||||||
- `bin/omarchy-finalize-user` runs the per-user runtime finalization (skill symlinks, xdg-user-dirs, mime defaults, `install/user/all.sh`). Shipped user defaults are seeded by `/etc/skel` from `omarchy-settings`, not by this command. `bin/omarchy-reinstall-configs` is the explicit destructive resync of those defaults into an existing user's `$HOME`.
|
|
||||||
- leaf scripts under `install/` are sourced by `run_logged $OMARCHY_INSTALL/path/to/script.sh` and intentionally do not have shebangs.
|
|
||||||
- avoid `exit` in sourced setup scripts unless intentionally aborting setup.
|
|
||||||
- use `$OMARCHY_INSTALL` and `$OMARCHY_PATH` instead of hard-coded Omarchy paths.
|
|
||||||
- keep root-scoped hardware setup under `install/hardware/` and orchestrate it through `install/hardware/all.sh`.
|
|
||||||
- keep every per-user setup leaf under `install/user/` (including `install/user/hardware/` and `install/user/first-run/`) so it is clear what must run for each user.
|
|
||||||
- The base install supplies matching kernel headers before hardware setup. DKMS installers should install their driver packages and assume the headers exist.
|
|
||||||
- prefer helper commands for package and command checks where available.
|
|
||||||
|
|
||||||
Raw `command -v`, `pacman`, and `pacman-key` are acceptable in package-helper
|
|
||||||
contexts where direct package-manager behavior is the point of the script.
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# Omarchy Shell Development
|
|
||||||
|
|
||||||
Read this before editing the Quickshell desktop under `shell/`.
|
|
||||||
|
|
||||||
The Quickshell desktop runs as a single long-running process out of
|
|
||||||
`shell/`. Hyprland autostart launches it directly with `quickshell -n -p`;
|
|
||||||
do not start additional standalone Quickshell instances for individual
|
|
||||||
components.
|
|
||||||
|
|
||||||
Run `omarchy-restart-shell` after making changes to QML files.
|
|
||||||
|
|
||||||
## Plugin contract
|
|
||||||
|
|
||||||
- First-party plugins live directly under `shell/plugins/` or one category
|
|
||||||
level deeper, such as `shell/plugins/panels/weather/`. First-party bar-only
|
|
||||||
widgets may use adjacent `*.manifest.json` files. Third-party plugins live
|
|
||||||
at `~/.config/omarchy/plugins/<id>/` with a `manifest.json` at the root.
|
|
||||||
- Every plugin manifest declares `schemaVersion`, `id`, `name`, `version`,
|
|
||||||
`kinds`, and `entryPoints`. See
|
|
||||||
[`docs/omarchy-shell.md`](../../docs/omarchy-shell.md) and
|
|
||||||
`shell/services/PluginRegistry.qml` for the current contract; fields such as
|
|
||||||
`activation` are optional.
|
|
||||||
- Entry-point QML files are `Item`s (not `ShellRoot`), and accept the shell-injected properties `omarchyPath`, `shell`, `manifest`, and `pluginRegistry` / `barWidgetRegistry` as appropriate. First-party plugins receive the host objects. Third-party plugins receive capability-scoped facades: ordinary plugins may look up and control only their own service and lifecycle, built-in clones retain narrow source-specific configuration and UI compatibility, menu plugins receive an application-library facade, and plugins can read detached scalar bar state; full-bar plugins additionally receive detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities must be stamped from trusted first-party manifests, and third-party registry views and bar configuration must be detached snapshots rather than shared objects. These facades reduce accidental authority but are not a same-process QML sandbox: a visual bar widget can walk its parent hierarchy to ordinary host objects. Authentication services must therefore remain outside both `ShellRoot._services` and the host QObject tree. Do not expose authentication services through new third-party-facing properties.
|
|
||||||
- Panel / overlay / menu plugins must expose `open(payloadJson)` and
|
|
||||||
`close()` lifecycle methods for `shell summon` and `shell hide`.
|
|
||||||
|
|
||||||
## IPC
|
|
||||||
|
|
||||||
- `bin/omarchy-shell` is the canonical IPC entry point. It forwards to
|
|
||||||
the running shell and does not start it. Prefer it over re-implementing
|
|
||||||
direct Quickshell socket calls in every CLI.
|
|
||||||
- The `shell` IPC target exposes lifecycle and configuration methods including
|
|
||||||
`ping`, `summon`, `hide`, `toggle`, `call`, `rescanPlugins`, `reloadConfig`,
|
|
||||||
`setPluginEnabled`, and `listPlugins`. `shell.qml` also registers
|
|
||||||
`image-selector`, which drives the `omarchy.image-picker` panel.
|
|
||||||
- Individual plugins register their own IPC targets, named for the plugin rather
|
|
||||||
than for where they appear: the background switcher registers `background`, and
|
|
||||||
bar widgets register one target each — `omarchy.indicators`,
|
|
||||||
`omarchy.system-update`, `omarchy.clock`. There is no `bar` target.
|
|
||||||
|
|
||||||
## Editing widget files with glyphs
|
|
||||||
|
|
||||||
Widget files in `shell/plugins/bar/widgets/` contain Nerd Font glyphs as raw
|
|
||||||
unicode characters. Agent file-editing tools can strip multi-byte codepoints
|
|
||||||
in some positions — do **not** rewrite widget files wholesale through those
|
|
||||||
tools. For glyph fixes, make a targeted edit with the surrounding context, or
|
|
||||||
use a Python script that inserts codepoints via `chr(0xXXXXX)`.
|
|
||||||
@@ -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,49 +26,32 @@ declare -A ROUTE_IS_ALIAS
|
|||||||
declare -A BINARY_TO_KEY
|
declare -A BINARY_TO_KEY
|
||||||
declare -A GROUP_DESCRIPTIONS
|
declare -A GROUP_DESCRIPTIONS
|
||||||
|
|
||||||
GROUP_DESCRIPTIONS[agent]="AI coding agent usage data"
|
GROUP_DESCRIPTIONS[ac]="AC power detection"
|
||||||
GROUP_DESCRIPTIONS[audio]="Audio input and output controls"
|
|
||||||
GROUP_DESCRIPTIONS[bar]="Omarchy shell bar layout and settings"
|
|
||||||
GROUP_DESCRIPTIONS[battery]="Battery status helpers"
|
GROUP_DESCRIPTIONS[battery]="Battery status helpers"
|
||||||
GROUP_DESCRIPTIONS[bluetooth]="Bluetooth device controls"
|
|
||||||
GROUP_DESCRIPTIONS[branch]="Omarchy git branch management"
|
GROUP_DESCRIPTIONS[branch]="Omarchy git branch management"
|
||||||
GROUP_DESCRIPTIONS[branding]="About and screensaver branding"
|
GROUP_DESCRIPTIONS[branding]="About and screensaver branding"
|
||||||
GROUP_DESCRIPTIONS[brightness]="Display and keyboard brightness"
|
GROUP_DESCRIPTIONS[brightness]="Display and keyboard brightness"
|
||||||
GROUP_DESCRIPTIONS[capture]="Screenshots and screen recording"
|
GROUP_DESCRIPTIONS[capture]="Screenshots and screen recording"
|
||||||
GROUP_DESCRIPTIONS[channel]="Omarchy release channel management"
|
GROUP_DESCRIPTIONS[channel]="Omarchy release channel management"
|
||||||
GROUP_DESCRIPTIONS[clipboard]="Clipboard helpers"
|
|
||||||
GROUP_DESCRIPTIONS[cmd]="Command and shortcut helpers"
|
GROUP_DESCRIPTIONS[cmd]="Command and shortcut helpers"
|
||||||
GROUP_DESCRIPTIONS[config]="System configuration helpers"
|
GROUP_DESCRIPTIONS[config]="System configuration helpers"
|
||||||
GROUP_DESCRIPTIONS[debug]="Diagnostics and support logs"
|
GROUP_DESCRIPTIONS[debug]="Diagnostics and support logs"
|
||||||
GROUP_DESCRIPTIONS[finalize]="Finalize user setup"
|
|
||||||
GROUP_DESCRIPTIONS[default]="Default application selection"
|
GROUP_DESCRIPTIONS[default]="Default application selection"
|
||||||
GROUP_DESCRIPTIONS[dev]="Omarchy development tools"
|
GROUP_DESCRIPTIONS[dev]="Omarchy development tools"
|
||||||
GROUP_DESCRIPTIONS[disk]="Disk performance helpers"
|
|
||||||
GROUP_DESCRIPTIONS[display]="Display and text scaling"
|
|
||||||
GROUP_DESCRIPTIONS[dns]="DNS resolver configuration"
|
|
||||||
GROUP_DESCRIPTIONS[drive]="Drive selection and encryption"
|
GROUP_DESCRIPTIONS[drive]="Drive selection and encryption"
|
||||||
GROUP_DESCRIPTIONS[file]="File selection helpers"
|
|
||||||
GROUP_DESCRIPTIONS[font]="Font management"
|
GROUP_DESCRIPTIONS[font]="Font management"
|
||||||
GROUP_DESCRIPTIONS[games]="Game launchers and helpers"
|
|
||||||
GROUP_DESCRIPTIONS[hibernation]="Hibernation setup and removal"
|
GROUP_DESCRIPTIONS[hibernation]="Hibernation setup and removal"
|
||||||
GROUP_DESCRIPTIONS[hook]="User hook runner"
|
GROUP_DESCRIPTIONS[hook]="User hook runner"
|
||||||
GROUP_DESCRIPTIONS[hw]="Hardware detection and controls"
|
GROUP_DESCRIPTIONS[hw]="Hardware detection and controls"
|
||||||
GROUP_DESCRIPTIONS[hyprland]="Hyprland window, monitor, and toggle controls"
|
GROUP_DESCRIPTIONS[hyprland]="Hyprland window, monitor, and toggle controls"
|
||||||
GROUP_DESCRIPTIONS[install]="Optional software installers"
|
GROUP_DESCRIPTIONS[install]="Optional software installers"
|
||||||
GROUP_DESCRIPTIONS[installed]="Installed optional service checks"
|
|
||||||
GROUP_DESCRIPTIONS[launch]="Application launchers"
|
GROUP_DESCRIPTIONS[launch]="Application launchers"
|
||||||
GROUP_DESCRIPTIONS[menu]="Omarchy menu commands"
|
GROUP_DESCRIPTIONS[menu]="Omarchy menu commands"
|
||||||
GROUP_DESCRIPTIONS[migrate]="Migration runner"
|
GROUP_DESCRIPTIONS[migrate]="Migration runner"
|
||||||
GROUP_DESCRIPTIONS[monitor]="Monitor status helpers"
|
|
||||||
GROUP_DESCRIPTIONS[network]="Network status helpers"
|
|
||||||
GROUP_DESCRIPTIONS[notification]="Notification helpers"
|
GROUP_DESCRIPTIONS[notification]="Notification helpers"
|
||||||
GROUP_DESCRIPTIONS[mise]="Mise tool wrappers"
|
GROUP_DESCRIPTIONS[npx]="NPX package wrappers"
|
||||||
GROUP_DESCRIPTIONS[openclaw]="OpenClaw agent platform setup"
|
|
||||||
GROUP_DESCRIPTIONS[osd]="On-screen display status helpers"
|
|
||||||
GROUP_DESCRIPTIONS[pkg]="Package management helpers"
|
GROUP_DESCRIPTIONS[pkg]="Package management helpers"
|
||||||
GROUP_DESCRIPTIONS[plugin]="Omarchy shell plugin and bar widget management"
|
|
||||||
GROUP_DESCRIPTIONS[plymouth]="Plymouth boot theme management"
|
GROUP_DESCRIPTIONS[plymouth]="Plymouth boot theme management"
|
||||||
GROUP_DESCRIPTIONS[power]="Power supply detection"
|
|
||||||
GROUP_DESCRIPTIONS[powerprofiles]="Power profile management"
|
GROUP_DESCRIPTIONS[powerprofiles]="Power profile management"
|
||||||
GROUP_DESCRIPTIONS[refresh]="Reset config to defaults"
|
GROUP_DESCRIPTIONS[refresh]="Reset config to defaults"
|
||||||
GROUP_DESCRIPTIONS[reinstall]="Reinstall and reset workflows"
|
GROUP_DESCRIPTIONS[reinstall]="Reinstall and reset workflows"
|
||||||
@@ -76,16 +59,16 @@ GROUP_DESCRIPTIONS[reminder]="Desktop notification reminders"
|
|||||||
GROUP_DESCRIPTIONS[remove]="Removal workflows"
|
GROUP_DESCRIPTIONS[remove]="Removal workflows"
|
||||||
GROUP_DESCRIPTIONS[restart]="Restart Omarchy components"
|
GROUP_DESCRIPTIONS[restart]="Restart Omarchy components"
|
||||||
GROUP_DESCRIPTIONS[setup]="Interactive setup wizards"
|
GROUP_DESCRIPTIONS[setup]="Interactive setup wizards"
|
||||||
GROUP_DESCRIPTIONS[shell]="Omarchy shell IPC helpers"
|
|
||||||
GROUP_DESCRIPTIONS[screensaver]="Screensaver branding and animation"
|
GROUP_DESCRIPTIONS[screensaver]="Screensaver branding and animation"
|
||||||
GROUP_DESCRIPTIONS[snapshot]="System snapshots"
|
GROUP_DESCRIPTIONS[snapshot]="System snapshots"
|
||||||
GROUP_DESCRIPTIONS[sudo]="Sudo configuration helpers"
|
GROUP_DESCRIPTIONS[sudo]="Sudo configuration helpers"
|
||||||
GROUP_DESCRIPTIONS[system]="System status, reboot, shutdown, logout, and lock"
|
GROUP_DESCRIPTIONS[swayosd]="SwayOSD status display helpers"
|
||||||
GROUP_DESCRIPTIONS[tailscale]="Tailscale helpers"
|
GROUP_DESCRIPTIONS[system]="Reboot, shutdown, logout, and lock"
|
||||||
GROUP_DESCRIPTIONS[theme]="Theme management"
|
GROUP_DESCRIPTIONS[theme]="Theme management"
|
||||||
GROUP_DESCRIPTIONS[toggle]="Toggle Omarchy features"
|
GROUP_DESCRIPTIONS[toggle]="Toggle Omarchy features"
|
||||||
GROUP_DESCRIPTIONS[transcode]="Image and video transcoding"
|
GROUP_DESCRIPTIONS[transcode]="Image and video transcoding"
|
||||||
GROUP_DESCRIPTIONS[tui]="Terminal UI launchers"
|
GROUP_DESCRIPTIONS[tui]="Terminal UI launchers"
|
||||||
|
GROUP_DESCRIPTIONS[tz]="Timezone selection"
|
||||||
GROUP_DESCRIPTIONS[update]="Omarchy and system updates"
|
GROUP_DESCRIPTIONS[update]="Omarchy and system updates"
|
||||||
GROUP_DESCRIPTIONS[version]="Version and channel information"
|
GROUP_DESCRIPTIONS[version]="Version and channel information"
|
||||||
GROUP_DESCRIPTIONS[voxtype]="Voxtype dictation"
|
GROUP_DESCRIPTIONS[voxtype]="Voxtype dictation"
|
||||||
@@ -121,31 +104,6 @@ append_pipe_value() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# A route can resolve to a binary before all args are consumed (`update aur` only matches the `update` binary, leaving `aur` and `--help` as leftovers)
|
|
||||||
# --help must never be lost among those leftovers and get forwarded into the real command, so this checks the whole remainder rather than just the first token.
|
|
||||||
# A `--` marks everything after it as belonging to the command itself, so scanning stops there.
|
|
||||||
remaining_has_help_flag() {
|
|
||||||
local token=""
|
|
||||||
|
|
||||||
for token in "$@"; do
|
|
||||||
[[ $token == "--" ]] && break
|
|
||||||
[[ $token == "--help" || $token == "-h" ]] && return 0
|
|
||||||
done
|
|
||||||
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
remaining_has_json_flag() {
|
|
||||||
local token=""
|
|
||||||
|
|
||||||
for token in "$@"; do
|
|
||||||
[[ $token == "--" ]] && break
|
|
||||||
[[ $token == "--json" ]] && return 0
|
|
||||||
done
|
|
||||||
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
register_route() {
|
register_route() {
|
||||||
local route="$1"
|
local route="$1"
|
||||||
local key="$2"
|
local key="$2"
|
||||||
@@ -171,6 +129,7 @@ register_command() {
|
|||||||
local name=""
|
local name=""
|
||||||
local summary=""
|
local summary=""
|
||||||
local usage=""
|
local usage=""
|
||||||
|
local binary=""
|
||||||
local args=""
|
local args=""
|
||||||
local examples=""
|
local examples=""
|
||||||
local aliases=""
|
local aliases=""
|
||||||
@@ -260,6 +219,7 @@ register_command() {
|
|||||||
fallback_name="${fallback_name//-/ }"
|
fallback_name="${fallback_name//-/ }"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
[[ -z $binary ]] && binary="$file_binary"
|
||||||
[[ -z $group ]] && group="$fallback_group"
|
[[ -z $group ]] && group="$fallback_group"
|
||||||
[[ $name_seen != "true" ]] && name="$fallback_name"
|
[[ $name_seen != "true" ]] && name="$fallback_name"
|
||||||
[[ -z $summary && -n $fallback_summary ]] && summary="$fallback_summary"
|
[[ -z $summary && -n $fallback_summary ]] && summary="$fallback_summary"
|
||||||
@@ -269,9 +229,11 @@ register_command() {
|
|||||||
route+=" $name"
|
route+=" $name"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
usage="$route"
|
if [[ -z $usage ]]; then
|
||||||
if [[ -n $args ]]; then
|
usage="$route"
|
||||||
usage+=" $args"
|
if [[ -n $args ]]; then
|
||||||
|
usage+=" $args"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
[[ $requires_sudo == "true" ]] || requires_sudo="false"
|
[[ $requires_sudo == "true" ]] || requires_sudo="false"
|
||||||
@@ -281,7 +243,7 @@ register_command() {
|
|||||||
COMMAND_KEYS+=("$key")
|
COMMAND_KEYS+=("$key")
|
||||||
COMMAND_ROUTE["$key"]="$route"
|
COMMAND_ROUTE["$key"]="$route"
|
||||||
COMMAND_FALLBACK_ROUTE["$key"]="$fallback_route"
|
COMMAND_FALLBACK_ROUTE["$key"]="$fallback_route"
|
||||||
COMMAND_BINARY["$key"]="$file_binary"
|
COMMAND_BINARY["$key"]="$binary"
|
||||||
COMMAND_GROUP["$key"]="$group"
|
COMMAND_GROUP["$key"]="$group"
|
||||||
COMMAND_NAME["$key"]="$name"
|
COMMAND_NAME["$key"]="$name"
|
||||||
COMMAND_SUMMARY["$key"]="$summary"
|
COMMAND_SUMMARY["$key"]="$summary"
|
||||||
@@ -293,7 +255,7 @@ register_command() {
|
|||||||
COMMAND_HAS_SUMMARY["$key"]="$has_summary"
|
COMMAND_HAS_SUMMARY["$key"]="$has_summary"
|
||||||
COMMAND_METADATA_ERRORS["$key"]="$metadata_errors"
|
COMMAND_METADATA_ERRORS["$key"]="$metadata_errors"
|
||||||
|
|
||||||
BINARY_TO_KEY["$file_binary"]="$key"
|
BINARY_TO_KEY["$binary"]="$key"
|
||||||
register_route "$route" "$key"
|
register_route "$route" "$key"
|
||||||
register_route "$fallback_route" "$key"
|
register_route "$fallback_route" "$key"
|
||||||
|
|
||||||
@@ -639,31 +601,25 @@ show_commands_markdown() {
|
|||||||
done < <(sorted_keys "$include_all")
|
done < <(sorted_keys "$include_all")
|
||||||
}
|
}
|
||||||
|
|
||||||
emit_command_record() {
|
|
||||||
local key="$1"
|
|
||||||
|
|
||||||
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
|
||||||
"${COMMAND_ROUTE[$key]}" \
|
|
||||||
"${COMMAND_BINARY[$key]}" \
|
|
||||||
"${COMMAND_GROUP[$key]}" \
|
|
||||||
"${COMMAND_NAME[$key]}" \
|
|
||||||
"${COMMAND_SUMMARY[$key]}" \
|
|
||||||
"${COMMAND_REQUIRES_SUDO[$key]}" \
|
|
||||||
"${COMMAND_HIDDEN[$key]}" \
|
|
||||||
"${COMMAND_ARGS[$key]}" \
|
|
||||||
"${COMMAND_EXAMPLES[$key]}" \
|
|
||||||
"${COMMAND_ALIASES[$key]}" \
|
|
||||||
"${COMMAND_FALLBACK_ROUTE[$key]}" \
|
|
||||||
"${COMMAND_USAGE[$key]}"
|
|
||||||
}
|
|
||||||
|
|
||||||
emit_command_records() {
|
emit_command_records() {
|
||||||
local include_all="$1"
|
local include_all="$1"
|
||||||
local key=""
|
local key=""
|
||||||
|
|
||||||
while IFS= read -r key; do
|
while IFS= read -r key; do
|
||||||
[[ -n $key ]] || continue
|
[[ -n $key ]] || continue
|
||||||
emit_command_record "$key"
|
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||||||
|
"${COMMAND_ROUTE[$key]}" \
|
||||||
|
"${COMMAND_BINARY[$key]}" \
|
||||||
|
"${COMMAND_GROUP[$key]}" \
|
||||||
|
"${COMMAND_NAME[$key]}" \
|
||||||
|
"${COMMAND_SUMMARY[$key]}" \
|
||||||
|
"${COMMAND_REQUIRES_SUDO[$key]}" \
|
||||||
|
"${COMMAND_HIDDEN[$key]}" \
|
||||||
|
"${COMMAND_ARGS[$key]}" \
|
||||||
|
"${COMMAND_EXAMPLES[$key]}" \
|
||||||
|
"${COMMAND_ALIASES[$key]}" \
|
||||||
|
"${COMMAND_FALLBACK_ROUTE[$key]}" \
|
||||||
|
"${COMMAND_USAGE[$key]}"
|
||||||
done < <(sorted_keys "$include_all")
|
done < <(sorted_keys "$include_all")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -732,7 +688,23 @@ show_commands_check() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
show_command_json() {
|
show_command_json() {
|
||||||
emit_command_record "$1" | jq -Rn "$(commands_json_filter) | {ok: true, command: .commands[0]}"
|
local key="$1"
|
||||||
|
|
||||||
|
printf '%s\n' "$key" | while IFS= read -r key; do
|
||||||
|
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||||||
|
"${COMMAND_ROUTE[$key]}" \
|
||||||
|
"${COMMAND_BINARY[$key]}" \
|
||||||
|
"${COMMAND_GROUP[$key]}" \
|
||||||
|
"${COMMAND_NAME[$key]}" \
|
||||||
|
"${COMMAND_SUMMARY[$key]}" \
|
||||||
|
"${COMMAND_REQUIRES_SUDO[$key]}" \
|
||||||
|
"${COMMAND_HIDDEN[$key]}" \
|
||||||
|
"${COMMAND_ARGS[$key]}" \
|
||||||
|
"${COMMAND_EXAMPLES[$key]}" \
|
||||||
|
"${COMMAND_ALIASES[$key]}" \
|
||||||
|
"${COMMAND_FALLBACK_ROUTE[$key]}" \
|
||||||
|
"${COMMAND_USAGE[$key]}"
|
||||||
|
done | jq -Rn "$(commands_json_filter) | {ok: true, command: .commands[0]}"
|
||||||
}
|
}
|
||||||
|
|
||||||
parse_commands_args() {
|
parse_commands_args() {
|
||||||
@@ -954,7 +926,7 @@ dispatch_fast_or_help() {
|
|||||||
remaining=("${args[@]:DIRECT_RESOLVED_COUNT}")
|
remaining=("${args[@]:DIRECT_RESOLVED_COUNT}")
|
||||||
binary_path="$OMARCHY_BIN_DIR/$DIRECT_RESOLVED_BINARY"
|
binary_path="$OMARCHY_BIN_DIR/$DIRECT_RESOLVED_BINARY"
|
||||||
|
|
||||||
if remaining_has_help_flag "${remaining[@]}"; then
|
if (( ${#remaining[@]} > 0 )) && [[ ${remaining[0]} == "--help" || ${remaining[0]} == "-h" ]]; then
|
||||||
if ! load_command_by_binary "$DIRECT_RESOLVED_BINARY"; then
|
if ! load_command_by_binary "$DIRECT_RESOLVED_BINARY"; then
|
||||||
echo "Binary is missing or not executable: $DIRECT_RESOLVED_BINARY" >&2
|
echo "Binary is missing or not executable: $DIRECT_RESOLVED_BINARY" >&2
|
||||||
return 127
|
return 127
|
||||||
@@ -965,7 +937,7 @@ dispatch_fast_or_help() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
key="${BINARY_TO_KEY[$DIRECT_RESOLVED_BINARY]}"
|
key="${BINARY_TO_KEY[$DIRECT_RESOLVED_BINARY]}"
|
||||||
if remaining_has_json_flag "${remaining[@]}"; then
|
if [[ " ${remaining[*]} " == *" --json "* ]]; then
|
||||||
show_command_json "$key"
|
show_command_json "$key"
|
||||||
else
|
else
|
||||||
show_command_help "$key"
|
show_command_help "$key"
|
||||||
@@ -1018,8 +990,8 @@ dispatch_or_help() {
|
|||||||
key="$RESOLVED_KEY"
|
key="$RESOLVED_KEY"
|
||||||
remaining=("${args[@]:RESOLVED_COUNT}")
|
remaining=("${args[@]:RESOLVED_COUNT}")
|
||||||
|
|
||||||
if remaining_has_help_flag "${remaining[@]}"; then
|
if (( ${#remaining[@]} > 0 )) && [[ ${remaining[0]} == "--help" || ${remaining[0]} == "-h" ]]; then
|
||||||
if remaining_has_json_flag "${remaining[@]}"; then
|
if [[ " ${remaining[*]} " == *" --json "* ]]; then
|
||||||
show_command_json "$key"
|
show_command_json "$key"
|
||||||
else
|
else
|
||||||
show_command_help "$key"
|
show_command_help "$key"
|
||||||
|
|||||||
@@ -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,138 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:summary=Launch the default coding agent in a terminal
|
|
||||||
# omarchy:args=[--inline] [--pick]
|
|
||||||
# omarchy:examples=omarchy agent | omarchy agent --inline
|
|
||||||
|
|
||||||
inline=false
|
|
||||||
pick=false
|
|
||||||
|
|
||||||
# Flags only. A bare prompt would shadow the subcommands under this group, so
|
|
||||||
# prompts go through omarchy-agent-prompt, which passes one here as --prompt.
|
|
||||||
while (($#)); do
|
|
||||||
case "$1" in
|
|
||||||
--inline)
|
|
||||||
inline=true
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--pick)
|
|
||||||
pick=true
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--prompt)
|
|
||||||
prompt=${2:?--prompt needs a value}
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unexpected argument: $1" >&2
|
|
||||||
echo "To pass a prompt: omarchy agent prompt \"$*\"" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# Agents refuse to remember trust for $HOME, so launches from the keybinding or
|
|
||||||
# menu start in the work directory instead of re-asking on every session.
|
|
||||||
[[ $PWD == "$HOME" && -d $HOME/Work ]] && cd "$HOME/Work"
|
|
||||||
|
|
||||||
agent=$(omarchy-default-agent)
|
|
||||||
|
|
||||||
# Omarchy ships without a default, so there is nothing to launch until one is
|
|
||||||
# picked. --pick offers the choice instead of the error, which is what the
|
|
||||||
# keybinding wants: a keypress that opens nothing explains nothing.
|
|
||||||
if [[ -z $agent ]]; then
|
|
||||||
[[ $pick == "true" ]] && exec omarchy-menu summon setup.default.agent
|
|
||||||
echo "Choose default agent with: omarchy default agent <name>" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if omarchy-cmd-missing "$agent"; then
|
|
||||||
echo "$agent is not installed. Choose an installed agent with: omarchy default agent <name>" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Agents launched from the keybinding or menu run unattended, so each one starts
|
|
||||||
# with its own spelling of "don't stop to ask". Pi has no approval prompts.
|
|
||||||
case "$agent" in
|
|
||||||
opencode)
|
|
||||||
command=(opencode --auto)
|
|
||||||
[[ -n ${prompt:-} ]] && command+=(--prompt "$prompt")
|
|
||||||
;;
|
|
||||||
gemini)
|
|
||||||
command=(gemini --yolo)
|
|
||||||
[[ -n ${prompt:-} ]] && command+=(--prompt-interactive "$prompt")
|
|
||||||
;;
|
|
||||||
copilot)
|
|
||||||
command=(copilot --allow-all)
|
|
||||||
[[ -n ${prompt:-} ]] && command+=(--interactive "$prompt")
|
|
||||||
;;
|
|
||||||
crush)
|
|
||||||
# --yolo belongs to the interactive command only; `crush run` never prompts.
|
|
||||||
if [[ -n ${prompt:-} ]]; then
|
|
||||||
command=(crush run "$prompt")
|
|
||||||
else
|
|
||||||
command=(crush --yolo)
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
claude)
|
|
||||||
command=(claude --permission-mode auto)
|
|
||||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
|
||||||
;;
|
|
||||||
grok)
|
|
||||||
command=(grok --permission-mode bypassPermissions)
|
|
||||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
|
||||||
;;
|
|
||||||
openclaw)
|
|
||||||
# The launcher owns onboarding and the gateway dance: OpenClaw's terminal UI
|
|
||||||
# must attach to the running gateway (the embedded `openclaw chat` refuses to
|
|
||||||
# start while the gateway owns the state directory). It has no permission
|
|
||||||
# prompts to skip, and --message seeds the session while keeping it
|
|
||||||
# interactive.
|
|
||||||
command=(omarchy-launch-openclaw --tui)
|
|
||||||
[[ -n ${prompt:-} ]] && command+=(--message "$prompt")
|
|
||||||
;;
|
|
||||||
codex)
|
|
||||||
command=(codex --approve-for-me)
|
|
||||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
|
||||||
;;
|
|
||||||
cursor-agent)
|
|
||||||
# --yolo covers commands only; the workspace trust dialog has its own flag.
|
|
||||||
# A one-word prompt naming a subcommand (update, login, help) still runs that
|
|
||||||
# subcommand after a bare --, so the agent subcommand is named outright, and
|
|
||||||
# -- after it keeps a prompt starting with a dash from being read as an option.
|
|
||||||
command=(cursor-agent --yolo --trust)
|
|
||||||
[[ -n ${prompt:-} ]] && command+=(agent -- "$prompt")
|
|
||||||
;;
|
|
||||||
hermes)
|
|
||||||
if [[ -n ${prompt:-} ]]; then
|
|
||||||
command=(env -u HERMES_SESSION_SOURCE hermes chat --yolo --tui "--query=$prompt")
|
|
||||||
else
|
|
||||||
command=(hermes --yolo)
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
muse)
|
|
||||||
# --approval-mode never skips the tool prompts but keeps Muse's own sandbox.
|
|
||||||
command=(muse --approval-mode never)
|
|
||||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
|
||||||
;;
|
|
||||||
omp)
|
|
||||||
command=(omp --auto-approve)
|
|
||||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
|
||||||
;;
|
|
||||||
pi)
|
|
||||||
command=(pi)
|
|
||||||
[[ -n ${prompt:-} ]] && command+=("$prompt")
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unsupported default agent: $agent" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [[ $inline == "true" ]]; then
|
|
||||||
exec "${command[@]}"
|
|
||||||
else
|
|
||||||
# A fixed app-id rather than the default org.omarchy.<binary>, so every agent
|
|
||||||
# window shares one class for window rules and themes to single out.
|
|
||||||
exec omarchy-launch-tui --app-id=org.omarchy.agent "${command[@]}"
|
|
||||||
fi
|
|
||||||
@@ -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 [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--force | --limits-only) flags+=("$1") ;;
|
|
||||||
--except)
|
|
||||||
excluded[$2]=1
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
*) only+=("$1") ;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
|
|
||||||
wanted() {
|
|
||||||
local agent="$1"
|
|
||||||
[[ -n ${excluded[$agent]} ]] && return 1
|
|
||||||
(( ${#only[@]} == 0 )) && return 0
|
|
||||||
local candidate
|
|
||||||
for candidate in "${only[@]}"; do
|
|
||||||
[[ $candidate == "$agent" ]] && return 0
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
collect() {
|
|
||||||
local collector="$1" agent="$2"
|
|
||||||
local record tmp
|
|
||||||
if ! record=$("$collector" "${flags[@]}") || [[ -z $record ]] || ! jq -e . >/dev/null 2>&1 <<<"$record"; then
|
|
||||||
echo "omarchy-agent-usage-update: $agent collector failed" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
tmp=$(mktemp "$USAGE_DIR/.$agent.XXXXXX")
|
|
||||||
printf '%s\n' "$record" >"$tmp"
|
|
||||||
mv "$tmp" "$USAGE_DIR/$agent.json"
|
|
||||||
}
|
|
||||||
|
|
||||||
pids=()
|
|
||||||
for collector in "$OMARCHY_PATH"/bin/omarchy-agent-usage-*; do
|
|
||||||
[[ -x $collector ]] || continue
|
|
||||||
agent="${collector##*/omarchy-agent-usage-}"
|
|
||||||
[[ $agent == "update" ]] && continue
|
|
||||||
wanted "$agent" || continue
|
|
||||||
collect "$collector" "$agent" &
|
|
||||||
pids+=($!)
|
|
||||||
done
|
|
||||||
|
|
||||||
status=0
|
|
||||||
for pid in "${pids[@]}"; do
|
|
||||||
wait "$pid" || status=1
|
|
||||||
done
|
|
||||||
exit $status
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:summary=Apply Omarchy hardware-specific packages and system configuration
|
|
||||||
# omarchy:group=apply
|
|
||||||
# omarchy:requires-sudo=true
|
|
||||||
# omarchy:examples=omarchy apply hardware --install-user dhh
|
|
||||||
# omarchy:hidden=true
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<USAGE
|
|
||||||
Usage: omarchy apply hardware --install-user USER
|
|
||||||
omarchy apply hardware --defer-provisioning
|
|
||||||
|
|
||||||
Runs idempotent hardware detection/setup for the installed machine. This is
|
|
||||||
called by omarchy-apply-system during ISO finalization and can be rerun later
|
|
||||||
for diagnostics or after hardware/package updates.
|
|
||||||
|
|
||||||
--defer-provisioning runs without an install user (deferred-provisioning installs create the user at
|
|
||||||
first boot); group grants are recorded in /var/lib/omarchy/provisioning/groups.
|
|
||||||
USAGE
|
|
||||||
}
|
|
||||||
|
|
||||||
install_user="${OMARCHY_INSTALL_USER:-}"
|
|
||||||
defer_provisioning=0
|
|
||||||
while (($#)); do
|
|
||||||
case "$1" in
|
|
||||||
--install-user)
|
|
||||||
install_user="${2:-}"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--defer-provisioning)
|
|
||||||
defer_provisioning=1
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
-h|--help)
|
|
||||||
usage
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unknown option: $1" >&2
|
|
||||||
usage >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
if (( EUID != 0 )); then
|
|
||||||
echo "Error: omarchy-apply-hardware must run as root" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if (( defer_provisioning )); then
|
|
||||||
install_user=""
|
|
||||||
else
|
|
||||||
if [[ -z $install_user || $install_user == "root" ]]; then
|
|
||||||
echo "Error: --install-user must name the target non-root user" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! getent passwd "$install_user" >/dev/null; then
|
|
||||||
echo "Error: user '$install_user' does not exist" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
export OMARCHY_INSTALL_USER="$install_user"
|
|
||||||
export OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}"
|
|
||||||
export OMARCHY_INSTALL="${OMARCHY_INSTALL:-$OMARCHY_PATH/install}"
|
|
||||||
export OMARCHY_INSTALL_LOG_FILE="${OMARCHY_INSTALL_LOG_FILE:-/var/log/omarchy-install.log}"
|
|
||||||
export PATH="$OMARCHY_PATH/bin:$PATH"
|
|
||||||
|
|
||||||
source "$OMARCHY_INSTALL/helpers/logging.sh"
|
|
||||||
source "$OMARCHY_INSTALL/hardware/all.sh"
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:summary=Configure Quickshell lock screen authentication
|
|
||||||
# omarchy:requires-sudo=true
|
|
||||||
# omarchy:hidden=true
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Install and upgrade callers can start this helper as root. Ignore their PATH
|
|
||||||
# so optional commands never fall through to a user-writable directory.
|
|
||||||
if (( EUID == 0 )); then
|
|
||||||
export PATH=/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin
|
|
||||||
fi
|
|
||||||
|
|
||||||
target_user=${OMARCHY_INSTALL_USER:-${SUDO_USER:-}}
|
|
||||||
if [[ -z $target_user && -n ${PKEXEC_UID:-} ]]; then
|
|
||||||
target_user=$(getent passwd "$PKEXEC_UID" | cut -d: -f1)
|
|
||||||
fi
|
|
||||||
target_user=${target_user:-$USER}
|
|
||||||
|
|
||||||
as_root() {
|
|
||||||
if (( EUID == 0 )); then
|
|
||||||
"$@"
|
|
||||||
else
|
|
||||||
sudo "$@"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "Configuring lock screen password authentication..."
|
|
||||||
|
|
||||||
as_root tee /etc/pam.d/omarchy-lock-password >/dev/null <<'EOF'
|
|
||||||
#%PAM-1.0
|
|
||||||
auth required pam_faillock.so preauth silent deny=10 unlock_time=120
|
|
||||||
-auth [success=2 default=ignore] pam_systemd_home.so
|
|
||||||
auth [success=1 default=bad] pam_unix.so try_first_pass nullok
|
|
||||||
auth [default=die] pam_faillock.so authfail deny=10 unlock_time=120
|
|
||||||
auth optional pam_permit.so
|
|
||||||
auth required pam_env.so
|
|
||||||
auth required pam_faillock.so authsucc
|
|
||||||
account include system-local-login
|
|
||||||
EOF
|
|
||||||
|
|
||||||
if [[ -x /usr/bin/fprintd-list ]] &&
|
|
||||||
/usr/bin/fprintd-list "$target_user" 2>/dev/null | grep -qi finger; then
|
|
||||||
echo "Configuring lock screen fingerprint authentication..."
|
|
||||||
as_root tee /etc/pam.d/omarchy-lock-fingerprint >/dev/null <<'EOF'
|
|
||||||
#%PAM-1.0
|
|
||||||
auth required pam_fprintd.so
|
|
||||||
account include system-local-login
|
|
||||||
EOF
|
|
||||||
else
|
|
||||||
as_root rm -f /etc/pam.d/omarchy-lock-fingerprint
|
|
||||||
fi
|
|
||||||
|
|
||||||
# omarchy-shell can't reach a running shell during chroot install. The echo
|
|
||||||
# is just confirmation, so swallow the failure rather than letting it become
|
|
||||||
# the script's exit code.
|
|
||||||
if omarchy-shell lock status >/dev/null 2>&1; then
|
|
||||||
echo "Lock screen authentication configured."
|
|
||||||
fi
|
|
||||||
@@ -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
|
wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle >/dev/null
|
||||||
|
|
||||||
if wpctl get-volume @DEFAULT_AUDIO_SOURCE@ | grep -q MUTED; then
|
if pactl get-source-mute @DEFAULT_SOURCE@ | rg -q 'yes'; then
|
||||||
omarchy-brightness-keyboard-mute on
|
led=on
|
||||||
omarchy-osd -i microphone-muted -m "Microphone muted"
|
osd_message='Microphone muted'
|
||||||
|
osd_icon='microphone-sensitivity-muted-symbolic'
|
||||||
else
|
else
|
||||||
omarchy-brightness-keyboard-mute off
|
led=off
|
||||||
omarchy-osd -i microphone -m "Microphone on"
|
osd_message='Microphone on'
|
||||||
|
osd_icon='audio-input-microphone-symbolic'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
omarchy-brightness-keyboard-mute "$led"
|
||||||
|
|
||||||
|
omarchy-swayosd-client \
|
||||||
|
--custom-message "$osd_message" \
|
||||||
|
--custom-icon "$osd_icon"
|
||||||
@@ -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
|
#!/bin/bash
|
||||||
|
|
||||||
# omarchy:summary=Switch between audio outputs while preserving the mute status
|
# omarchy:summary=Switch between audio outputs while preserving the mute status. By default mapped to Super + Mute.
|
||||||
|
|
||||||
# Skip the physical sink an active speaker tuning fronts: rotating onto it would
|
sinks=$(pactl -f json list sinks | jq '[.[] | select((.ports | length == 0) or ([.ports[]? | .availability != "not available"] | any))]')
|
||||||
# silently bypass the tuning rather than pick a different output.
|
sinks_count=$(echo "$sinks" | jq '. | length')
|
||||||
fronted=$(omarchy-audio-tuning fronted-sink 2>/dev/null || true)
|
|
||||||
|
|
||||||
sinks=$(timeout 2 pactl -f json list sinks |
|
|
||||||
jq --arg fronted "$fronted" '[.[]
|
|
||||||
| select((.ports | length == 0) or ([.ports[]? | .availability != "not available"] | any))
|
|
||||||
| select($fronted == "" or .name != $fronted)]')
|
|
||||||
sinks_count=$(jq 'length' <<<"$sinks")
|
|
||||||
|
|
||||||
if (( sinks_count == 0 )); then
|
if (( sinks_count == 0 )); then
|
||||||
omarchy-osd -m "No audio devices found"
|
omarchy-swayosd-client --custom-message "No audio devices found"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
current_sink_name=$(timeout 2 pactl get-default-sink)
|
current_sink_name=$(pactl get-default-sink)
|
||||||
current_sink_index=$(jq -r --arg name "$current_sink_name" 'map(.name) | index($name)' <<<"$sinks")
|
current_sink_index=$(echo "$sinks" | jq -r --arg name "$current_sink_name" 'map(.name) | index($name)')
|
||||||
|
|
||||||
if [[ $current_sink_index != "null" ]]; then
|
if [[ $current_sink_index != "null" ]]; then
|
||||||
next_sink_index=$(((current_sink_index + 1) % sinks_count))
|
next_sink_index=$(((current_sink_index + 1) % sinks_count))
|
||||||
@@ -26,23 +19,28 @@ else
|
|||||||
next_sink_index=0
|
next_sink_index=0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
next_sink=$(jq -c ".[$next_sink_index]" <<<"$sinks")
|
next_sink=$(echo "$sinks" | jq -r ".[$next_sink_index]")
|
||||||
next_sink_name=$(jq -r '.name' <<<"$next_sink")
|
next_sink_name=$(echo "$next_sink" | jq -r '.name')
|
||||||
next_sink_description=$(jq -r '.description // .properties."device.description" // .name' <<<"$next_sink")
|
|
||||||
# A tuning sink sits at a fixed 100% and unmuted while real loudness lives on the
|
next_sink_description=$(echo "$next_sink" | jq -r '.description')
|
||||||
# physical sink beneath it, so read the level from whichever sink actually carries
|
if [[ $next_sink_description == "(null)" ]] || [[ $next_sink_description == "null" ]] || [[ -z $next_sink_description ]]; then
|
||||||
# it or the OSD contradicts the volume keys.
|
# For Bluetooth devices, the friendly name is on the Device entry (device.id), not the Sink entry (object.id)
|
||||||
next_sink_effective=$(omarchy-audio-output-sink "$next_sink_name")
|
device_id=$(echo "$next_sink" | jq -r '.properties."device.id"')
|
||||||
next_sink_volume=$(timeout 2 pactl get-sink-volume "$next_sink_effective" 2>/dev/null |
|
if [[ $device_id != "null" ]] && [[ -n $device_id ]]; then
|
||||||
awk 'NR == 1 {for (i = 1; i <= NF; i++) if ($i ~ /%$/) {sub("%", "", $i); print $i; exit}}')
|
next_sink_description=$(wpctl status | grep -E "^\s*│?\s+${device_id}\." | sed -E 's/^.*[0-9]+\.\s+//' | sed -E 's/\s+\[.*$//')
|
||||||
[[ -n $next_sink_volume ]] || next_sink_volume=$(jq -r '.volume | to_entries[0].value.value_percent | sub("%"; "") | tonumber' <<<"$next_sink")
|
fi
|
||||||
if [[ $(timeout 2 pactl get-sink-mute "$next_sink_effective" 2>/dev/null) == *yes ]]; then
|
# Fall back to object.id lookup if device.id didn't yield a result
|
||||||
next_sink_is_muted=true
|
if [[ -z $next_sink_description ]]; then
|
||||||
else
|
sink_id=$(echo "$next_sink" | jq -r '.properties."object.id"')
|
||||||
next_sink_is_muted=false
|
next_sink_description=$(wpctl status | grep -E "\s+\*?\s+${sink_id}\." | sed -E 's/^.*[0-9]+\.\s+//' | sed -E 's/\s+\[.*$//')
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $next_sink_is_muted == "true" ]] || (( next_sink_volume == 0 )); then
|
next_sink_volume=$(echo "$next_sink" | jq -r \
|
||||||
|
'.volume | to_entries[0].value.value_percent | sub("%"; "")')
|
||||||
|
next_sink_is_muted=$(echo "$next_sink" | jq -r '.mute')
|
||||||
|
|
||||||
|
if [[ $next_sink_is_muted = "true" ]] || (( next_sink_volume == 0 )); then
|
||||||
icon_state="muted"
|
icon_state="muted"
|
||||||
elif (( next_sink_volume <= 33 )); then
|
elif (( next_sink_volume <= 33 )); then
|
||||||
icon_state="low"
|
icon_state="low"
|
||||||
@@ -52,8 +50,13 @@ else
|
|||||||
icon_state="high"
|
icon_state="high"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
next_sink_volume_icon="sink-volume-${icon_state}-symbolic"
|
||||||
|
|
||||||
if [[ $next_sink_name != $current_sink_name ]]; then
|
if [[ $next_sink_name != $current_sink_name ]]; then
|
||||||
omarchy-audio-output-set-default "$(jq -r '.index' <<<"$next_sink")" "$next_sink_name"
|
next_sink_wpid=$(echo "$next_sink" | jq -r '.properties."object.id"')
|
||||||
|
wpctl set-default "$next_sink_wpid"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
omarchy-osd -i "volume-${icon_state}" -m "$next_sink_description"
|
omarchy-swayosd-client \
|
||||||
|
--custom-message "$next_sink_description" \
|
||||||
|
--custom-icon "$next_sink_volume_icon"
|
||||||
@@ -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
|
#!/bin/bash
|
||||||
|
|
||||||
# omarchy:summary=Returns a formatted battery status string with percentage and power draw/charge.
|
# omarchy:summary=Returns a formatted battery status string with percentage and power draw/charge.
|
||||||
# omarchy:args=[--shell]
|
|
||||||
|
|
||||||
shell_output=false
|
battery_info=$(upower -i $(upower -e | grep BAT))
|
||||||
power_supply_path="${OMARCHY_POWER_SUPPLY_PATH:-/sys/class/power_supply}"
|
|
||||||
|
|
||||||
case "${1:-}" in
|
percentage=$(echo "$battery_info" | awk '/percentage/ {
|
||||||
"")
|
print int($2)
|
||||||
;;
|
exit
|
||||||
--shell)
|
|
||||||
shell_output=true
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Usage: omarchy-battery-status [--shell]" >&2
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
battery=$(upower -e 2>/dev/null | grep BAT | head -n 1)
|
|
||||||
[[ -z $battery ]] && exit 0
|
|
||||||
|
|
||||||
battery_info=$(upower -i "$battery")
|
|
||||||
|
|
||||||
percentage=$(awk '/percentage/ { print int($2); exit }' <<<"$battery_info")
|
|
||||||
capacity=$(awk '/energy-full:/ { printf "%d", $2; exit }' <<<"$battery_info")
|
|
||||||
time_remaining=$(awk '/time to (empty|full)/ {
|
|
||||||
value = $4
|
|
||||||
unit = $5
|
|
||||||
if (unit ~ /^minute/) {
|
|
||||||
printf "%dm", int(value)
|
|
||||||
} else {
|
|
||||||
hours = int(value)
|
|
||||||
minutes = int((value - hours) * 60)
|
|
||||||
if (minutes > 0) {
|
|
||||||
printf "%dh %dm", hours, minutes
|
|
||||||
} else {
|
|
||||||
printf "%dh", hours
|
|
||||||
}
|
|
||||||
}
|
|
||||||
exit
|
|
||||||
}' <<<"$battery_info")
|
|
||||||
power_rate_raw=$(awk '/energy-rate/ { print $2; exit }' <<<"$battery_info")
|
|
||||||
native_path=$(awk '/native-path/ { print $2; exit }' <<<"$battery_info")
|
|
||||||
battery_path="$power_supply_path/$native_path"
|
|
||||||
|
|
||||||
# UPower's energy-rate can lag the kernel telemetry by tens of seconds. Use
|
|
||||||
# the instantaneous sysfs reading when available so the open panel stays live.
|
|
||||||
if [[ -r $battery_path/power_now ]]; then
|
|
||||||
power_rate_raw=$(awk -v microwatts="$(<"$battery_path/power_now")" 'BEGIN { print microwatts / 1000000 }')
|
|
||||||
elif [[ -r $battery_path/current_now && -r $battery_path/voltage_now ]]; then
|
|
||||||
power_rate_raw=$(awk \
|
|
||||||
-v microamps="$(<"$battery_path/current_now")" \
|
|
||||||
-v microvolts="$(<"$battery_path/voltage_now")" \
|
|
||||||
'BEGIN { print microamps * microvolts / 1000000000000 }')
|
|
||||||
fi
|
|
||||||
|
|
||||||
power_rate=$(awk -v rate="${power_rate_raw:-0}" 'BEGIN {
|
|
||||||
rounded = sprintf("%.1f", rate)
|
|
||||||
sub(/\.0$/, "", rounded)
|
|
||||||
print rounded
|
|
||||||
}')
|
}')
|
||||||
state=$(awk '/state/ { print $2; exit }' <<<"$battery_info")
|
|
||||||
threshold_start=$(awk '/charge-start-threshold:/ { gsub(/%/, "", $2); print int($2); exit }' <<<"$battery_info")
|
|
||||||
threshold_end=$(awk '/charge-end-threshold:/ { gsub(/%/, "", $2); print int($2); exit }' <<<"$battery_info")
|
|
||||||
|
|
||||||
[[ -z $threshold_end ]] && threshold_end=$(cat "$power_supply_path"/BAT*/charge_control_end_threshold 2>/dev/null | head -1)
|
power_rate=$(echo "$battery_info" | awk '/energy-rate/ {
|
||||||
[[ -z $threshold_start ]] && threshold_start=$(cat "$power_supply_path"/BAT*/charge_control_start_threshold 2>/dev/null | head -1)
|
rounded = sprintf("%.1f", $2)
|
||||||
|
sub(/\.0$/, "", rounded)
|
||||||
|
print rounded
|
||||||
|
exit
|
||||||
|
}')
|
||||||
|
|
||||||
ac_online=false
|
state=$(echo "$battery_info" | awk '/state/ { print $2; exit }')
|
||||||
for supply in "$power_supply_path"/*; do
|
time_remaining=$(omarchy-battery-remaining-time)
|
||||||
[[ -r $supply/type ]] || continue
|
capacity=$(omarchy-battery-capacity)
|
||||||
[[ $(<"$supply/type") == "Mains" ]] || continue
|
|
||||||
[[ -r $supply/online ]] || continue
|
|
||||||
|
|
||||||
if [[ $(<"$supply/online") == "1" ]]; then
|
if [[ $state == "charging" ]]; then
|
||||||
ac_online=true
|
echo " Battery ${percentage}% · ${time_remaining} to full · ${power_rate}W / ${capacity}Wh"
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
charge_idle=false
|
|
||||||
if awk -v rate="${power_rate_raw:-0}" 'BEGIN { exit !(rate <= 0.2) }'; then
|
|
||||||
charge_idle=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
charge_holding=false
|
|
||||||
if [[ $ac_online == "true" && -n $threshold_end ]]; then
|
|
||||||
if [[ $state == "pending-charge" ]]; then
|
|
||||||
charge_holding=true
|
|
||||||
elif [[ $state == "fully-charged" ]] && (( percentage < 99 )); then
|
|
||||||
charge_holding=true
|
|
||||||
elif [[ $state == "charging" && $charge_idle == "true" ]] && (( threshold_end < 99 && percentage >= threshold_end )); then
|
|
||||||
charge_holding=true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ $shell_output == "true" ]]; then
|
|
||||||
printf 'percentage\t%s\n' "${percentage}%"
|
|
||||||
if [[ $charge_holding == "true" ]]; then
|
|
||||||
printf 'state\tholding\n'
|
|
||||||
else
|
|
||||||
printf 'state\t%s\n' "$state"
|
|
||||||
fi
|
|
||||||
printf 'rate\t%s\n' "${power_rate}W"
|
|
||||||
printf 'size\t%s\n' "${capacity}Wh"
|
|
||||||
printf 'time\t%s\n' "$time_remaining"
|
|
||||||
|
|
||||||
cycles=$(cat "$power_supply_path"/BAT*/cycle_count 2>/dev/null | head -1)
|
|
||||||
|
|
||||||
[[ -n $cycles ]] && printf 'cycles\t%s\n' "$cycles"
|
|
||||||
|
|
||||||
if [[ -n $threshold_end ]]; then
|
|
||||||
if [[ -n $threshold_start && $threshold_start != $threshold_end ]]; then
|
|
||||||
printf 'threshold\t%s-%s%%\n' "$threshold_start" "$threshold_end"
|
|
||||||
else
|
|
||||||
printf 'threshold\t%s%%\n' "$threshold_end"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ $charge_holding == "true" ]]; then
|
|
||||||
if [[ -n $threshold_start && $threshold_start != $threshold_end ]]; then
|
|
||||||
threshold_label="${threshold_start}-${threshold_end}%"
|
|
||||||
else
|
|
||||||
threshold_label="${threshold_end}%"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Battery ${percentage}% · Holding at ${threshold_label} · ${power_rate}W / ${capacity}Wh"
|
|
||||||
elif [[ $state == "charging" ]]; then
|
|
||||||
echo "Battery ${percentage}% · ${time_remaining} to full · ${power_rate}W / ${capacity}Wh"
|
|
||||||
else
|
else
|
||||||
echo "Battery ${percentage}% · ${time_remaining} left · ${power_rate}W / ${capacity}Wh"
|
echo " Battery ${percentage}% · ${time_remaining} left · ${power_rate}W / ${capacity}Wh"
|
||||||
fi
|
fi
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:summary=Control a Bluetooth device
|
|
||||||
# omarchy:group=bluetooth
|
|
||||||
# omarchy:args=[pair|connect|disconnect|forget] <address>
|
|
||||||
# omarchy:examples=omarchy bluetooth device connect 00:11:22:33:44:55
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
echo "Usage: omarchy-bluetooth-device [pair|connect|disconnect|forget] <address>" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
action=${1:-}
|
|
||||||
address=${2:-}
|
|
||||||
|
|
||||||
[[ $action == "pair" || $action == "connect" || $action == "disconnect" || $action == "forget" ]] || usage
|
|
||||||
[[ $address =~ ^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$ ]] || usage
|
|
||||||
|
|
||||||
power_on() {
|
|
||||||
[[ $(timeout 2s bluetoothctl show 2>/dev/null) == *"Powered: yes"* ]] && return
|
|
||||||
# Not bluetoothctl directly: Bluetooth is turned off by an rfkill soft block,
|
|
||||||
# and BlueZ refuses to power an adapter up while one is set.
|
|
||||||
omarchy-bluetooth-power on || true
|
|
||||||
}
|
|
||||||
|
|
||||||
trust_device() {
|
|
||||||
bluetoothctl trust "$address" >/dev/null 2>&1 || true
|
|
||||||
}
|
|
||||||
|
|
||||||
case "$action" in
|
|
||||||
pair)
|
|
||||||
power_on
|
|
||||||
timeout 20s bluetoothctl pair "$address" >/dev/null 2>&1 || true
|
|
||||||
trust_device
|
|
||||||
timeout 20s bluetoothctl connect "$address" >/dev/null 2>&1 || true
|
|
||||||
;;
|
|
||||||
connect)
|
|
||||||
power_on
|
|
||||||
trust_device
|
|
||||||
timeout 20s bluetoothctl connect "$address" >/dev/null 2>&1 || true
|
|
||||||
;;
|
|
||||||
disconnect)
|
|
||||||
timeout 10s bluetoothctl disconnect "$address" >/dev/null 2>&1 || true
|
|
||||||
;;
|
|
||||||
forget)
|
|
||||||
power_on
|
|
||||||
timeout 10s bluetoothctl disconnect "$address" >/dev/null 2>&1 || true
|
|
||||||
timeout 10s bluetoothctl remove "$address" >/dev/null 2>&1 || true
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:summary=Turn Bluetooth on or off, remembered across reboots
|
|
||||||
# omarchy:group=bluetooth
|
|
||||||
# omarchy:args=<on|off|toggle|is-on>
|
|
||||||
|
|
||||||
# BlueZ never persists an adapter's Powered property, so turning Bluetooth off
|
|
||||||
# through bluetoothctl lasts only until the next boot. The rfkill soft block does
|
|
||||||
# persist: systemd-rfkill saves every switch under /var/lib/systemd/rfkill and
|
|
||||||
# restores it early on the next boot, which is its entire job. Blocking is also
|
|
||||||
# what the kernel hands every radio at once, so a machine with two controllers
|
|
||||||
# gets both, where bluetoothctl only ever addresses the default one.
|
|
||||||
#
|
|
||||||
# So the block is the state, and BlueZ follows it: unblocking leaves AutoEnable
|
|
||||||
# at its stock default and bluetoothd powers the adapter up by itself. Every
|
|
||||||
# Omarchy path that turns Bluetooth on or off goes through here, because a plain
|
|
||||||
# `bluetoothctl power on` fails outright while the block is set.
|
|
||||||
|
|
||||||
POWER_WAIT_SECONDS=${OMARCHY_BLUETOOTH_POWER_WAIT_SECONDS:-2}
|
|
||||||
|
|
||||||
controllers() {
|
|
||||||
timeout 2s bluetoothctl list 2>/dev/null | awk '{print $2}'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Any controller counts. The block is all-or-nothing across the radios, so the
|
|
||||||
# state has to be read the same way; a bare `bluetoothctl show` would report the
|
|
||||||
# default controller and miss a powered dongle sitting behind it.
|
|
||||||
powered() {
|
|
||||||
local controller
|
|
||||||
|
|
||||||
for controller in $(controllers); do
|
|
||||||
[[ $(timeout 2s bluetoothctl show "$controller" 2>/dev/null) == *"Powered: yes"* ]] && return 0
|
|
||||||
done
|
|
||||||
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# One deadline around the whole wait rather than a fixed number of probes: every
|
|
||||||
# probe can sit on its own timeout when D-Bus is wedged, and counting probes then
|
|
||||||
# stretches a two-second wait into half a minute.
|
|
||||||
wait_powered() {
|
|
||||||
local deadline=$((SECONDS + POWER_WAIT_SECONDS))
|
|
||||||
|
|
||||||
while :; do
|
|
||||||
powered && return 0
|
|
||||||
((SECONDS < deadline)) || return 1
|
|
||||||
sleep 0.2
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
power_on() {
|
|
||||||
rfkill unblock bluetooth
|
|
||||||
|
|
||||||
# Usually all it takes: with AutoEnable at its default, bluetoothd powers the
|
|
||||||
# adapter up on its own once the block is gone. It will not do that for an
|
|
||||||
# adapter powered down without a block, so ask directly before giving up.
|
|
||||||
wait_powered && return 0
|
|
||||||
|
|
||||||
timeout 5s bluetoothctl power on >/dev/null 2>&1
|
|
||||||
wait_powered && return 0
|
|
||||||
|
|
||||||
echo "omarchy-bluetooth-power: adapter did not come up" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
case "${1:-}" in
|
|
||||||
on)
|
|
||||||
power_on
|
|
||||||
;;
|
|
||||||
off)
|
|
||||||
# No bluetoothctl power off to go with this: the block already drops the
|
|
||||||
# adapter to Powered: no, and it is the half that survives the reboot.
|
|
||||||
rfkill block bluetooth
|
|
||||||
;;
|
|
||||||
toggle)
|
|
||||||
if powered; then
|
|
||||||
rfkill block bluetooth
|
|
||||||
else
|
|
||||||
power_on
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
is-on)
|
|
||||||
powered
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Usage: omarchy-bluetooth-power <on|off|toggle|is-on>" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# omarchy:summary=Set the branch for Omarchy's git repository.
|
||||||
|
# omarchy:args=<master|rc|dev>
|
||||||
|
|
||||||
|
if (($# == 0)); then
|
||||||
|
echo "Usage: omarchy-branch-set [master|rc|dev]"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
branch="$1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $branch != "master" && $branch != "rc" && $branch != "dev" ]]; then
|
||||||
|
echo "Error: Invalid branch '$branch'. Must be one of: master, rc, dev"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git -C $OMARCHY_PATH switch $branch
|
||||||
@@ -10,8 +10,8 @@ set -euo pipefail
|
|||||||
|
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
image)
|
image)
|
||||||
image=$(omarchy-file-select --title "Pick PNG or SVG for About" --extensions "png svg")
|
image=$(omarchy-menu-file "Logo image" "$HOME" "svg png")
|
||||||
if omarchy-transcode-ascii "$image" ~/.config/omarchy/branding/about.txt --width 54 --height 26; then
|
if [[ -n $image ]] && omarchy-transcode-ascii "$image" ~/.config/omarchy/branding/about.txt --width 54 --height 26 --mode block; then
|
||||||
omarchy-launch-about >/dev/null 2>&1
|
omarchy-launch-about >/dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
|
|||||||