Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3f35c57fb |
No files matched your search
@@ -1,7 +1,7 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Suggestion
|
- name: Suggestion
|
||||||
url: https://github.com/omacom/omarchy/discussions/categories/suggestions
|
url: https://github.com/basecamp/omarchy/discussions/categories/suggestions
|
||||||
about: Suggest a new feature, change to existing feature, or other ideas in Discussions.
|
about: Suggest a new feature, change to existing feature, or other ideas in Discussions.
|
||||||
- name: Support
|
- name: Support
|
||||||
url: https://omarchy.org/discord
|
url: https://omarchy.org/discord
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
# Security at Omarchy
|
|
||||||
|
|
||||||
## Report a vulnerability
|
|
||||||
|
|
||||||
If you believe you’ve found a security vulnerability in Omarchy, please tell the [Omarchy Security Team](https://omarchy.org/teams/#security) privately so we have an opportunity to investigate and fix it before it is made public.
|
|
||||||
|
|
||||||
[security@omarchy.org](mailto:security@omarchy.org?subject=Security%20report)
|
|
||||||
|
|
||||||
Please don’t report potential vulnerabilities publicly in GitHub Issues, Discord, or social media before they’ve been resolved.
|
|
||||||
|
|
||||||
## What is a vulnerability?
|
|
||||||
|
|
||||||
We consider a bug a security vulnerability when it can be exploited to cross a meaningful security boundary: an untrusted or lower-privileged party gains access, permissions, or control they didn’t already have.
|
|
||||||
|
|
||||||
Code that could be more robust but does not cross a security boundary is an improvement rather than a security vulnerability. We may still merge a proposed fix and credit the reporter in our release notes.
|
|
||||||
|
|
||||||
Eligibility for our [security credits](https://omarchy.org/security/credits/) page depends on whether a report identifies a confirmed security vulnerability, not on its severity.
|
|
||||||
|
|
||||||
## What to include
|
|
||||||
|
|
||||||
Give us enough information to understand and reproduce the issue:
|
|
||||||
|
|
||||||
- The affected component and Omarchy version.
|
|
||||||
- An explanation of what an attacker can do before and after exploitation.
|
|
||||||
- Steps to reproduce the issue and any proof of concept.
|
|
||||||
- Your preferred contact details for follow-up.
|
|
||||||
|
|
||||||
## Responsible disclosure
|
|
||||||
|
|
||||||
Please act in good faith while investigating and reporting vulnerabilities:
|
|
||||||
|
|
||||||
- Only test systems and accounts you own or have explicit permission to test.
|
|
||||||
- Avoid privacy violations, disruption, data destruction, and service degradation.
|
|
||||||
- Don’t exploit a vulnerability beyond what is needed to demonstrate it.
|
|
||||||
- Give us a reasonable opportunity to investigate and address the issue before publishing details.
|
|
||||||
|
|
||||||
We’ll review your report and keep you informed as we’re able while we work toward a resolution.
|
|
||||||
|
|
||||||
## Credits
|
|
||||||
|
|
||||||
Researchers who privately report a confirmed security vulnerability and give us the chance to ship a fix are thanked on the [security credits](https://omarchy.org/security/credits/) page. Accepted improvements that don’t cross a security boundary may still be credited in our release notes.
|
|
||||||
|
|
||||||
Credits link to each reporter’s X profile and show their avatar. For duplicate reports, only the first reporter is eligible for credit.
|
|
||||||
|
|
||||||
## Regular bugs and support
|
|
||||||
|
|
||||||
For anything that isn’t a security vulnerability, please use the [Omarchy issue tracker](https://github.com/omacom/omarchy/issues).
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
|
|
||||||
# Python bytecode (orchestrator)
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
|
|
||||||
.claude/
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"workspace": {
|
|
||||||
"library": [
|
|
||||||
"/usr/share/hypr/stubs"
|
|
||||||
],
|
|
||||||
"checkThirdParty": false
|
|
||||||
},
|
|
||||||
"diagnostics": {
|
|
||||||
"globals": ["hl"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +1,18 @@
|
|||||||
# Task Guides
|
|
||||||
|
|
||||||
Deeper instructions for specific kinds of work live in `agents/skills/`. Read the
|
|
||||||
matching guide before starting:
|
|
||||||
|
|
||||||
- [`agents/skills/command-metadata.md`](agents/skills/command-metadata.md) - adding or changing commands in `bin/`
|
|
||||||
- [`agents/skills/install-scripts.md`](agents/skills/install-scripts.md) - working under `install/` or on system/user setup commands
|
|
||||||
- [`agents/skills/shell-dev.md`](agents/skills/shell-dev.md) - editing the Quickshell desktop under `shell/`
|
|
||||||
- [`agents/skills/icon-font.md`](agents/skills/icon-font.md) - adding branded glyphs to `default/fonts/omarchy/omarchy.ttf`
|
|
||||||
- [`agents/skills/acceptance-tests.md`](agents/skills/acceptance-tests.md) - writing or running graphical acceptance tests under `test/acceptance.d/`
|
|
||||||
- [`agents/skills/visual-verification.md`](agents/skills/visual-verification.md) - verifying any change with a visual effect in the running UI
|
|
||||||
- [`agents/skills/migrations.md`](agents/skills/migrations.md) - creating or changing migrations under `migrations/`
|
|
||||||
|
|
||||||
# Documentation Layout
|
|
||||||
|
|
||||||
Three documentation trees, split by genre and audience:
|
|
||||||
|
|
||||||
- `agents/skills/` - task procedure ("do this when doing X"), for anyone working on the codebase
|
|
||||||
- `docs/` - reference on how the system is shaped (file layout, update pipeline, theming, shell architecture), for anyone working on the codebase; skills link here for depth
|
|
||||||
- `manual/` - end-user documentation for using Omarchy, published; never codebase internals
|
|
||||||
|
|
||||||
# Style
|
# Style
|
||||||
|
|
||||||
- In markdown documents (`plans/`, `docs/`, `manual/`), write full lines — no hard wrapping at 80 columns; break only at structural boundaries like headings and list items
|
|
||||||
- Two spaces for indentation, no tabs
|
- Two spaces for indentation, no tabs
|
||||||
- Use bash 5 conditionals: use `[[ ]]` for string/file tests and `(( ))` for numeric tests
|
- Use bash 5 conditionals: use `[[ ]]` for string/file tests and `(( ))` for numeric tests
|
||||||
- In `[[ ]]`, don't quote variables, but do quote string literals when comparing values (e.g., `[[ $branch == "dev" ]]`)
|
- In `[[ ]]`, don't quote variables, but do quote string literals when comparing values (e.g., `[[ $branch == "dev" ]]`)
|
||||||
- Prefer `(( ))` over numeric operators inside `[[ ]]` (e.g., `(( count < 50 ))`, not `[[ $count -lt 50 ]]`)
|
- Prefer `(( ))` over numeric operators inside `[[ ]]` (e.g., `(( count < 50 ))`, not `[[ $count -lt 50 ]]`)
|
||||||
- Prefer a full `if`/`else` conditional for simple two-path control flow; don't rely on `exec` or `exit` in one branch to make following statements unreachable
|
|
||||||
- For strings/paths with spaces, quote them instead of escaping spaces with `\ ` (e.g., `"$APP_DIR/Disk Usage.desktop"`, not `$APP_DIR/Disk\ Usage.desktop`)
|
- For strings/paths with spaces, quote them instead of escaping spaces with `\ ` (e.g., `"$APP_DIR/Disk Usage.desktop"`, not `$APP_DIR/Disk\ Usage.desktop`)
|
||||||
- Shebangs must use `#!/bin/bash` consistently (never `#!/usr/bin/env bash`). A security-sensitive entrypoint may use the exact `#!/bin/bash -p` form only when it must suppress `BASH_ENV` and exported-function startup injection before its first command; that exception must be explained at the boundary and covered by a regression that rejects an ordinary Bash launch with a decoy `-p` argument.
|
- Shebangs must use `#!/bin/bash` consistently (never `#!/usr/bin/env bash`)
|
||||||
- Scripts under `install/` and `migrations/` may be sourced and intentionally omit shebangs
|
- Scripts under `install/` and `migrations/` may be sourced and intentionally omit shebangs
|
||||||
|
|
||||||
# Command Naming
|
# Command Naming
|
||||||
|
|
||||||
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:
|
||||||
|
|
||||||
@@ -54,85 +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`;
|
|
||||||
[`docs/menu.md`](docs/menu.md) covers the schema, guards, and providers.
|
|
||||||
- Do not add `aliases` to new menu entries. Aliases are reserved for
|
|
||||||
established alternate names users already type, kept for compatibility.
|
|
||||||
|
|
||||||
# Region
|
|
||||||
|
|
||||||
- `/etc/omarchy/region` holds the region the installer derived from the timezone (`cn`); no file means Global. The profile lives in `default/regions/cn/`, its contract in `docs/regions.md`.
|
|
||||||
- `install/config/region.sh` links the `cn` developer mirrors into `/etc` and `/.cargo` and refuses to replace a regular file it did not create.
|
|
||||||
|
|
||||||
# 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;
|
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.
|
||||||
[`docs/testing.md`](docs/testing.md) covers how the suites are shaped. Current
|
|
||||||
test entry points:
|
|
||||||
|
|
||||||
- `./test/all` - aggregate runner for CLI and shell tests; it intentionally does not run graphical acceptance tests
|
|
||||||
- `./test/cli` - CLI routing, command metadata, theme helpers, and safe dispatch coverage
|
|
||||||
- `./test/shell` - all Omarchy shell tests under `test/shell.d/`
|
|
||||||
|
|
||||||
New Omarchy shell tests should live in `test/shell.d/*-test.sh` so `./test/shell` picks them up automatically. Source `test/shell.d/base-test.sh` for shared root-path discovery, assertions, and Node test helpers.
|
|
||||||
|
|
||||||
The graphical acceptance suite runs in a disposable VM, not in the active
|
|
||||||
development session; see [`agents/skills/acceptance-tests.md`](agents/skills/acceptance-tests.md).
|
|
||||||
|
|
||||||
Visual changes must be verified in the running UI in addition to automated
|
|
||||||
tests; follow [`agents/skills/visual-verification.md`](agents/skills/visual-verification.md).
|
|
||||||
|
|
||||||
# Refresh Pattern
|
# Refresh Pattern
|
||||||
|
|
||||||
To copy a default config to user config with automatic backup:
|
To copy a default config to user config with automatic backup:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
omarchy-refresh-config hypr/hyprland.lua
|
omarchy-refresh-config hypr/hyprlock.conf
|
||||||
```
|
```
|
||||||
|
|
||||||
This copies `$OMARCHY_PATH/config/hypr/hyprland.lua` to `~/.config/hypr/hyprland.lua`. The argument
|
This copies `~/.local/share/omarchy/config/hypr/hyprlock.conf` to `~/.config/hypr/hyprlock.conf`.
|
||||||
is interpolated into both paths and only checked with `[[ -e ]]`, so pass a plain relative path: a
|
|
||||||
name containing `..` resolves and copies, landing outside `~/.config` rather than being rejected.
|
# Migrations
|
||||||
|
|
||||||
|
To create a new migration, run `omarchy-dev-add-migration --no-edit`. This creates a migration file named after the unix timestamp of the last commit.
|
||||||
|
|
||||||
|
New migration format:
|
||||||
|
- File permissions must be `0644` (`-rw-r--r--`); migrations are sourced, not executed directly
|
||||||
|
- No shebang line
|
||||||
|
- Start with an `echo` describing what the migration does
|
||||||
|
- Use `$OMARCHY_PATH` to reference the omarchy directory
|
||||||
|
- Prefer helper commands such as `omarchy-cmd-present`, `omarchy-cmd-missing`, `omarchy-pkg-present`, and `omarchy-pkg-missing`
|
||||||
|
|
||||||
|
Some older migrations predate these rules. Do not copy older migrations that start with shebangs, omit the leading `echo`, or hard-code `~/.local/share/omarchy`.
|
||||||
|
|
||||||
|
Migrations may use raw `pacman`, `command -v`, or direct config edits when needed for historical compatibility or one-off repair work.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```bash
|
||||||
|
echo "Disable fingerprint in hyprlock if fingerprint auth is not configured"
|
||||||
|
|
||||||
|
if omarchy-cmd-missing fprintd-list || ! fprintd-list "$USER" 2>/dev/null | grep -q "finger"; then
|
||||||
|
sed -i 's/fingerprint:enabled = .*/fingerprint:enabled = false/' ~/.config/hypr/hyprlock.conf
|
||||||
|
fi
|
||||||
|
```
|
||||||
@@ -1,77 +1,9 @@
|
|||||||
# Omarchy
|
# Omarchy
|
||||||
|
|
||||||
Omarchy is a beautiful, fun & agentic Linux distribution by DHH.
|
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.
|
|
||||||
|
|
||||||
- [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,172 +0,0 @@
|
|||||||
# Omarchy migrations
|
|
||||||
|
|
||||||
Read this before creating or changing migrations under `migrations/`.
|
|
||||||
|
|
||||||
Omarchy migrations are one-time repair scripts for existing installs. They are
|
|
||||||
used when a package update needs to change state that pacman cannot safely own by
|
|
||||||
itself.
|
|
||||||
|
|
||||||
## Migration model
|
|
||||||
|
|
||||||
Migrations live in:
|
|
||||||
|
|
||||||
```text
|
|
||||||
migrations/*.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
They run as the current Omarchy user through `omarchy-migrate`, normally during
|
|
||||||
`omarchy update`. A migration may touch user/session state (`~/.config`,
|
|
||||||
`~/.local`, user systemd, browser/editor prefs, DBus/session state), and may also
|
|
||||||
perform machine-wide repairs when needed.
|
|
||||||
|
|
||||||
Completion state is per-user:
|
|
||||||
|
|
||||||
```text
|
|
||||||
~/.local/state/omarchy/migrations/<migration filename>
|
|
||||||
```
|
|
||||||
|
|
||||||
That means every user gets a chance to run every migration. Migrations run as the
|
|
||||||
user; privileged operations should invoke the appropriate helper or privilege
|
|
||||||
prompt themselves. Migrations must be idempotent: if one user already applied a
|
|
||||||
machine-wide repair, the same migration running for another user should detect
|
|
||||||
that and no-op.
|
|
||||||
|
|
||||||
## When migrations run
|
|
||||||
|
|
||||||
### During `omarchy update`
|
|
||||||
|
|
||||||
`omarchy update` is the normal update path. It runs package updates, then:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
omarchy-migrate
|
|
||||||
omarchy-hook post-update
|
|
||||||
```
|
|
||||||
|
|
||||||
`omarchy-migrate` waits for any active pacman transaction to finish, then runs
|
|
||||||
all pending migrations for the current user in the visible update terminal.
|
|
||||||
|
|
||||||
### At login
|
|
||||||
|
|
||||||
Every graphical login starts `omarchy-migrate-notify.service` after
|
|
||||||
`graphical-session.target`. The notifier checks:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
omarchy-migrate --pending
|
|
||||||
```
|
|
||||||
|
|
||||||
It stays silent while `omarchy update` holds its lock, since that update applies
|
|
||||||
the pending migrations itself.
|
|
||||||
|
|
||||||
If that user has pending migrations, it shows a notification that opens a
|
|
||||||
terminal for:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
omarchy-migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
The notifier never runs migrations silently in the background.
|
|
||||||
|
|
||||||
This is what covers users who did not run the update themselves: someone who
|
|
||||||
bypassed the pacman guard with `sudo env OMARCHY_ALLOW_DIRECT_PACMAN=1 pacman
|
|
||||||
-Syu`, and any second user on the machine, whose migration markers are per-user
|
|
||||||
and therefore still missing after another user updated.
|
|
||||||
|
|
||||||
Login is the only trigger on purpose. Watching the packaged migration directory
|
|
||||||
also fires during a normal `omarchy update`, which prompts for migrations that
|
|
||||||
`omarchy-migrate` is about to run in the visible update terminal.
|
|
||||||
|
|
||||||
### Manually
|
|
||||||
|
|
||||||
Users can safely run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
omarchy-migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
at any time. Already-completed migrations are skipped.
|
|
||||||
|
|
||||||
## Inspecting pending migrations
|
|
||||||
|
|
||||||
Use:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
omarchy-migrate --pending
|
|
||||||
```
|
|
||||||
|
|
||||||
Exit behavior:
|
|
||||||
|
|
||||||
- `0` — one or more migrations are pending
|
|
||||||
- non-zero — no migrations are pending
|
|
||||||
|
|
||||||
Output is one pending migration per line:
|
|
||||||
|
|
||||||
```text
|
|
||||||
1781158082.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Creating a migration
|
|
||||||
|
|
||||||
Use the helper:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
omarchy-dev-add-migration --no-edit
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates:
|
|
||||||
|
|
||||||
```text
|
|
||||||
migrations/<unix timestamp>.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
New migration format:
|
|
||||||
|
|
||||||
- File permissions must be `0644` (`-rw-r--r--`). Migration runners execute them
|
|
||||||
with `bash -euo pipefail`, not through executable bits.
|
|
||||||
- No shebang line.
|
|
||||||
- Start with an `echo` describing what the migration does.
|
|
||||||
- Use `$OMARCHY_PATH` to reference the Omarchy directory.
|
|
||||||
- Be idempotent. Check existing state before changing it.
|
|
||||||
- Migrations are strictly ordered and synchronous. A migration that cannot finish must exit non-zero, remain pending, and stop the queue; never mark later migrations complete against state an earlier migration has not established.
|
|
||||||
- Use helper commands such as `omarchy-cmd-present`, `omarchy-cmd-missing`,
|
|
||||||
`omarchy-pkg-add`, `omarchy-pkg-drop`, `omarchy-pkg-present`, and
|
|
||||||
`omarchy-pkg-missing` when appropriate.
|
|
||||||
- Never restart the Omarchy shell. `omarchy update` restarts it unconditionally
|
|
||||||
after migrations run, and the login-time shell already runs current code and
|
|
||||||
hot-reloads `shell.json` edits.
|
|
||||||
- Raw `pacman`, `command -v`, and direct config edits are acceptable when
|
|
||||||
needed for one-off repair work.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
echo "Relink Neovim theme to Omarchy current state"
|
|
||||||
|
|
||||||
theme_link="$HOME/.config/nvim/lua/plugins/theme.lua"
|
|
||||||
current_relative_target="../../../../.local/state/omarchy/current/theme/neovim.lua"
|
|
||||||
|
|
||||||
[[ -L $theme_link ]] || exit 0
|
|
||||||
ln -sfn "$current_relative_target" "$theme_link"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing migrations
|
|
||||||
|
|
||||||
Run a migration against a temporary home when possible:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
HOME=$(mktemp -d) bash -euo pipefail migrations/<timestamp>.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
To rerun a migration locally, remove its marker and run the migrator:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rm ~/.local/state/omarchy/migrations/<migration>.sh
|
|
||||||
omarchy-migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
Keep a dedicated test while the migration is still being written or bugfixed, if it calls an Omarchy helper whose interface can still change, or if it is a security-sensitive privileged repair (FIDO2, leftover installer artifacts, udev, sshd). Once a one-shot rewrite has shipped in a tagged release and is frozen, drop the test even when that rewrite used sudo, pacman, or limine-mkinitcpio. Keep the migration itself for late-updaters. Tests of `omarchy-migrate`, the login notifier, and `omarchy-upgrade-to-quattro` stay.
|
|
||||||
|
|
||||||
Omarchy 4.0 is upgraded through `bin/omarchy-upgrade-to-quattro`, not through the
|
|
||||||
normal migration runner. Do not add compatibility migrations for old installer
|
|
||||||
layouts; put pre-4 package-layout transition work in the upgrade command instead.
|
|
||||||
|
|
||||||
Clearing a privileged file that a retired installer left on disk is the exception, and belongs in a migration whether or not that installer was part of a package layout transition. The upgrade command only runs on a machine still making the 3 to 4 crossing, so anything put there never reaches an install that crossed already, and it never runs at all for an installer that was retired on its own — while the file the installer wrote is still sitting on those machines. The upgrade command finishes by running `omarchy-migrate` (`run_post_upgrade_migrations`), so one migration reaches every population; a copy in the upgrade command would only be a second copy of the same predicate to keep correct. Such a migration must name the defect it clears and match what the old installer actually produced before deleting it. Leave safe administrator-authored files alone; if one still contains the vulnerable privileged action, preserve it under an inactive name rather than discarding custom content or leaving the action executable. A user config that depends on the same retired compatibility path may be repaired in that migration when doing so eliminates an overlapping migration, but only by matching and replacing the exact legacy path while preserving the rest of the file.
|
|
||||||
@@ -1,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,41 +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 writes to Omasnap's configured screenshot directory. Use `omarchy screenshot` for the interactive capture flow, which copies the capture and shows a timed preview. Use `omarchy screenshot --editor=overlay` to test annotation before output. 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=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,51 +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 agents, their usage, and subscription accounts"
|
GROUP_DESCRIPTIONS[ac]="AC power detection"
|
||||||
GROUP_DESCRIPTIONS[ascii]="Text drawn as ASCII art"
|
|
||||||
GROUP_DESCRIPTIONS[audio]="Audio input and output controls"
|
|
||||||
GROUP_DESCRIPTIONS[bar]="Omarchy shell bar layout and settings"
|
|
||||||
GROUP_DESCRIPTIONS[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[crash]="Crash notification controls"
|
|
||||||
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"
|
||||||
@@ -78,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"
|
||||||
@@ -123,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"
|
||||||
@@ -173,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=""
|
||||||
@@ -262,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"
|
||||||
@@ -271,10 +229,12 @@ register_command() {
|
|||||||
route+=" $name"
|
route+=" $name"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -z $usage ]]; then
|
||||||
usage="$route"
|
usage="$route"
|
||||||
if [[ -n $args ]]; then
|
if [[ -n $args ]]; then
|
||||||
usage+=" $args"
|
usage+=" $args"
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
[[ $requires_sudo == "true" ]] || requires_sudo="false"
|
[[ $requires_sudo == "true" ]] || requires_sudo="false"
|
||||||
[[ $hidden == "true" ]] || hidden="false"
|
[[ $hidden == "true" ]] || hidden="false"
|
||||||
@@ -283,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"
|
||||||
@@ -295,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"
|
||||||
|
|
||||||
@@ -371,20 +331,6 @@ command_requires_args() {
|
|||||||
[[ -n $required ]]
|
[[ -n $required ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
# A command whose documented arguments are all flags can't take a word, so a
|
|
||||||
# word after it names something else: `omarchy agent account` is the account
|
|
||||||
# commands, not an argument to omarchy-agent.
|
|
||||||
command_takes_only_flags() {
|
|
||||||
local key="$1"
|
|
||||||
local args="${COMMAND_ARGS[$key]}"
|
|
||||||
local token=""
|
|
||||||
|
|
||||||
[[ -n $args ]] || return 1
|
|
||||||
for token in $args; do
|
|
||||||
[[ $token == -* || $token == "[-"* ]] || return 1
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
load_group_commands() {
|
load_group_commands() {
|
||||||
local group="$1"
|
local group="$1"
|
||||||
local file=""
|
local file=""
|
||||||
@@ -655,9 +601,12 @@ show_commands_markdown() {
|
|||||||
done < <(sorted_keys "$include_all")
|
done < <(sorted_keys "$include_all")
|
||||||
}
|
}
|
||||||
|
|
||||||
emit_command_record() {
|
emit_command_records() {
|
||||||
local key="$1"
|
local include_all="$1"
|
||||||
|
local key=""
|
||||||
|
|
||||||
|
while IFS= read -r key; do
|
||||||
|
[[ -n $key ]] || continue
|
||||||
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' \
|
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_ROUTE[$key]}" \
|
||||||
"${COMMAND_BINARY[$key]}" \
|
"${COMMAND_BINARY[$key]}" \
|
||||||
@@ -671,15 +620,6 @@ emit_command_record() {
|
|||||||
"${COMMAND_ALIASES[$key]}" \
|
"${COMMAND_ALIASES[$key]}" \
|
||||||
"${COMMAND_FALLBACK_ROUTE[$key]}" \
|
"${COMMAND_FALLBACK_ROUTE[$key]}" \
|
||||||
"${COMMAND_USAGE[$key]}"
|
"${COMMAND_USAGE[$key]}"
|
||||||
}
|
|
||||||
|
|
||||||
emit_command_records() {
|
|
||||||
local include_all="$1"
|
|
||||||
local key=""
|
|
||||||
|
|
||||||
while IFS= read -r key; do
|
|
||||||
[[ -n $key ]] || continue
|
|
||||||
emit_command_record "$key"
|
|
||||||
done < <(sorted_keys "$include_all")
|
done < <(sorted_keys "$include_all")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -748,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() {
|
||||||
@@ -970,18 +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[@]} == 1 || ${#remaining[@]} == 2 )) && [[ ${remaining[0]} != -* ]] &&
|
if (( ${#remaining[@]} > 0 )) && [[ ${remaining[0]} == "--help" || ${remaining[0]} == "-h" ]]; then
|
||||||
{ (( ${#remaining[@]} == 1 )) || remaining_has_help_flag "${remaining[1]}"; } &&
|
|
||||||
group_has_child_commands "${DIRECT_RESOLVED_BINARY#omarchy-}-${remaining[0]}"; then
|
|
||||||
load_command_by_binary "$DIRECT_RESOLVED_BINARY"
|
|
||||||
key="${BINARY_TO_KEY[$DIRECT_RESOLVED_BINARY]}"
|
|
||||||
if [[ -n $key ]] && command_takes_only_flags "$key"; then
|
|
||||||
load_commands
|
|
||||||
show_prefix_help "$DIRECT_RESOLVED_ROUTE ${remaining[0]}" && return 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if remaining_has_help_flag "${remaining[@]}"; 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
|
||||||
@@ -992,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"
|
||||||
@@ -1045,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,168 +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 and Ori have none to skip.
|
|
||||||
case "$agent" in
|
|
||||||
opencode)
|
|
||||||
command=(opencode --auto)
|
|
||||||
[[ -n ${prompt:-} ]] && command+=(--prompt "$prompt")
|
|
||||||
;;
|
|
||||||
agy)
|
|
||||||
command=(agy --dangerously-skip-permissions)
|
|
||||||
[[ -n ${prompt:-} ]] && command+=(--prompt-interactive "$prompt")
|
|
||||||
;;
|
|
||||||
copilot)
|
|
||||||
command=(copilot --allow-all)
|
|
||||||
[[ -n ${prompt:-} ]] && command+=(--interactive "$prompt")
|
|
||||||
;;
|
|
||||||
crush)
|
|
||||||
# --yolo belongs to the interactive command only; `crush run` never prompts.
|
|
||||||
if [[ -n ${prompt:-} ]]; then
|
|
||||||
command=(crush run "$prompt")
|
|
||||||
else
|
|
||||||
command=(crush --yolo)
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
claude)
|
|
||||||
command=(claude --permission-mode auto)
|
|
||||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
|
||||||
;;
|
|
||||||
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")
|
|
||||||
;;
|
|
||||||
ori)
|
|
||||||
# Ori is a harness launcher, and `ori code` is the agent it runs itself.
|
|
||||||
# A prompt alone means one headless turn there, printed after the turn ends,
|
|
||||||
# so --interactive is what seeds the session with it and keeps the window.
|
|
||||||
command=(ori code)
|
|
||||||
[[ -n ${prompt:-} ]] && command+=(--interactive --prompt "$prompt")
|
|
||||||
;;
|
|
||||||
pi)
|
|
||||||
command=(pi)
|
|
||||||
[[ -n ${prompt:-} ]] && command+=("$prompt")
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unsupported default agent: $agent" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# A new session uses the current selection rather than its parent's account
|
|
||||||
# pin. Explicit provider homes still override the selection. Carry the new pin
|
|
||||||
# in the command, since terminals launched through uwsm don't inherit our env.
|
|
||||||
case "$agent" in
|
|
||||||
claude) home_env="CLAUDE_CONFIG_DIR" ;;
|
|
||||||
codex) home_env="CODEX_HOME" ;;
|
|
||||||
grok) home_env="GROK_HOME" ;;
|
|
||||||
*) home_env="" ;;
|
|
||||||
esac
|
|
||||||
if [[ -n $home_env ]]; then
|
|
||||||
account_pin="OMARCHY_AGENT_${agent^^}_HOME"
|
|
||||||
if [[ -v $account_pin && ${!home_env:-} == "${!account_pin}" ]]; then
|
|
||||||
unset "$home_env"
|
|
||||||
fi
|
|
||||||
unset "$account_pin"
|
|
||||||
if [[ -z ${!home_env:-} ]]; then
|
|
||||||
home=$(omarchy-agent-account-home "$agent")
|
|
||||||
account_env=(env -u "$home_env" "$account_pin=$home")
|
|
||||||
[[ -n $home ]] && account_env+=("$home_env=$home")
|
|
||||||
command=("${account_env[@]}" "${command[@]}")
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
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,223 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:summary=Sign in to a Claude, Codex, or Grok subscription
|
|
||||||
# omarchy:args=[--check] [--events] [--reauth <account>] [claude|codex|grok] [label]
|
|
||||||
# omarchy:examples=omarchy agent account add claude | omarchy agent account add Work | omarchy agent account add codex Side
|
|
||||||
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
# --check prints, per provider, whether adding one now would be its first
|
|
||||||
# sign-in, an additional account, or not possible yet. --events is for the
|
|
||||||
# agents panel: progress arrives as `@@omarchy <event> <detail>` lines mixed
|
|
||||||
# into the CLI's own output (which carries codes the panel shows), nothing
|
|
||||||
# waits on a terminal, and the result also lands as a notification in case
|
|
||||||
# the panel has closed.
|
|
||||||
# --reauth <account> signs an account that's already here in again, in its
|
|
||||||
# own home, for when its sign-in has lapsed.
|
|
||||||
check=false
|
|
||||||
events=false
|
|
||||||
reauth=""
|
|
||||||
while [[ ${1:-} == --* ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--check) check=true ;;
|
|
||||||
--events) events=true ;;
|
|
||||||
--reauth)
|
|
||||||
reauth="${2:-}"
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
*) break ;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
|
|
||||||
# Whether a provider's own home (~/.claude, ~/.codex, ~/.grok) is signed in.
|
|
||||||
primary_signed_in() {
|
|
||||||
omarchy-agent-account-state list "$1" | jq -e '.[0].accounts | any(.primary and .signedIn)' >/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ $check == "true" ]]; then
|
|
||||||
for p in claude codex grok; do
|
|
||||||
if primary_signed_in "$p"; then
|
|
||||||
echo "$p additional"
|
|
||||||
else
|
|
||||||
echo "$p first"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# The provider can be left out when it's your default agent:
|
|
||||||
# `omarchy agent account add Work`.
|
|
||||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
|
|
||||||
default=$(omarchy-default-agent)
|
|
||||||
[[ $default == "claude" || $default == "codex" || $default == "grok" ]] && set -- "$default" "$@"
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "${1:-}" in
|
|
||||||
claude) provider="claude"; name="Claude"; site="claude.ai"; package="claude"; home_env="CLAUDE_CONFIG_DIR" ;;
|
|
||||||
codex) provider="codex"; name="Codex"; site="chatgpt.com"; package="codex"; home_env="CODEX_HOME" ;;
|
|
||||||
grok) provider="grok"; name="Grok"; site="x.ai"; package="grok"; home_env="GROK_HOME" ;;
|
|
||||||
*)
|
|
||||||
echo "Usage: omarchy-agent-account-add [--check] [--events] [--reauth <account>] [claude|codex|grok] [label]" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
label="${2:-}"
|
|
||||||
|
|
||||||
say() {
|
|
||||||
if [[ $events == "true" ]]; then
|
|
||||||
printf '@@omarchy status %s\n' "$*"
|
|
||||||
else
|
|
||||||
echo "$*"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
finish() {
|
|
||||||
if [[ $events == "true" ]]; then
|
|
||||||
printf '@@omarchy done %s\n' "$1"
|
|
||||||
omarchy-notification-send -g "$1"
|
|
||||||
else
|
|
||||||
echo
|
|
||||||
echo "$1"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
fail() {
|
|
||||||
if [[ $events == "true" ]]; then
|
|
||||||
printf '@@omarchy error %s\n' "$1"
|
|
||||||
omarchy-notification-send -g -u critical "Couldn't add the $name account" "$1"
|
|
||||||
else
|
|
||||||
echo "$1" >&2
|
|
||||||
fi
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Cancelling from the panel stops the CLI's login too, and leaves no
|
|
||||||
# half-made home behind.
|
|
||||||
home=""
|
|
||||||
browser=""
|
|
||||||
cleanup() {
|
|
||||||
[[ -n $browser ]] && rm -f "$browser"
|
|
||||||
[[ -n $home && -d $home && $home == */.pending/* ]] && rm -rf "$home"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
trap 'pkill -TERM -P $$; exit 130' TERM INT
|
|
||||||
|
|
||||||
# Bash holds a trap until a foreground command finishes, and a login waits on
|
|
||||||
# the browser for as long as it takes. Running it in the background behind a
|
|
||||||
# `wait` lets Cancel stop it at once. It keeps this script's stdin, where the
|
|
||||||
# panel sends a pasted code.
|
|
||||||
interruptible() {
|
|
||||||
"$@" <&0 &
|
|
||||||
wait $!
|
|
||||||
}
|
|
||||||
|
|
||||||
if omarchy-cmd-missing "$provider"; then
|
|
||||||
say "Installing the $name CLI…"
|
|
||||||
if [[ $events == "true" ]]; then
|
|
||||||
mise use -g "$package" >/dev/null 2>&1 || fail "Installing the $name CLI didn't finish."
|
|
||||||
else
|
|
||||||
mise use -g "$package" || exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Your main browser is almost certainly signed in to the account you already
|
|
||||||
# have, and a login would quietly reuse it. The CLIs open their login page
|
|
||||||
# through $BROWSER, so this points it at a private window instead.
|
|
||||||
private_browser() {
|
|
||||||
browser=$(mktemp "${XDG_RUNTIME_DIR:-/tmp}/omarchy-account-browser.XXXXXX")
|
|
||||||
printf '#!/bin/bash\nexec omarchy-launch-browser --private "$1"\n' >"$browser"
|
|
||||||
chmod +x "$browser"
|
|
||||||
}
|
|
||||||
|
|
||||||
case "$provider" in
|
|
||||||
claude) login_command=(claude auth login) ;;
|
|
||||||
codex) login_command=(codex login) ;;
|
|
||||||
grok) login_command=(grok login) ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# The CLI's own home, whatever this shell has pointed it at.
|
|
||||||
login() {
|
|
||||||
interruptible env -u CLAUDE_CONFIG_DIR -u CODEX_HOME -u GROK_HOME "OMARCHY_AGENT_${provider^^}_HOME=" "${login_command[@]}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Signing an account that's already here in again, where it lives: the
|
|
||||||
# primary home through the normal browser, an added one through a private
|
|
||||||
# window. Its home is never recreated or removed.
|
|
||||||
if [[ -n $reauth ]]; then
|
|
||||||
if [[ $reauth == ":primary" ]]; then
|
|
||||||
account_home="$HOME/.$provider"
|
|
||||||
else
|
|
||||||
account_home=$(omarchy-agent-account-state homes "$provider" | awk -F'\t' -v id="$reauth" '$1 == id { print $2 }')
|
|
||||||
fi
|
|
||||||
[[ -n $account_home ]] || fail "No $name account named $reauth."
|
|
||||||
|
|
||||||
if [[ $account_home == "$HOME/.$provider" ]]; then
|
|
||||||
say "Sign in to $name again in the browser window that opens."
|
|
||||||
login || fail "The $name sign-in didn't finish."
|
|
||||||
else
|
|
||||||
say "Sign in again as this account in the private window that opens."
|
|
||||||
private_browser
|
|
||||||
interruptible env BROWSER="$browser" "$home_env=$account_home" "${login_command[@]}" || fail "The $name sign-in didn't finish."
|
|
||||||
fi
|
|
||||||
|
|
||||||
omarchy-agent-usage-update --force "$provider" >/dev/null 2>&1 &
|
|
||||||
finish "Signed in to $name again."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# The first subscription signs in where the CLI always looks, through your
|
|
||||||
# normal browser: nothing is signed in there yet that it could be mistaken for.
|
|
||||||
if ! primary_signed_in "$provider"; then
|
|
||||||
say "Sign in to $name in the browser window that opens."
|
|
||||||
if login && primary_signed_in "$provider"; then
|
|
||||||
# The first agent signed in on a machine with none chosen becomes the
|
|
||||||
# default, so `omarchy agent` and the panel's starter prompts have one to
|
|
||||||
# run. A default someone already picked is never touched.
|
|
||||||
if [[ -z $(omarchy-default-agent) ]]; then
|
|
||||||
mkdir -p "$HOME/.config/omarchy/defaults"
|
|
||||||
printf '%s\n' "$provider" >"$HOME/.config/omarchy/defaults/agent"
|
|
||||||
fi
|
|
||||||
omarchy-agent-usage-update --force "$provider" >/dev/null 2>&1 &
|
|
||||||
finish "Signed in to $name."
|
|
||||||
else
|
|
||||||
fail "The $name sign-in didn't finish."
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if [[ -z $label && $events == "false" && -t 0 ]]; then
|
|
||||||
label=$(gum input --placeholder "Work" --prompt "Name this $name account: ") || exit 130
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ $events == "true" ]]; then
|
|
||||||
say "Sign in as the account you're adding in the private window that opens."
|
|
||||||
else
|
|
||||||
cat <<EOF
|
|
||||||
|
|
||||||
Sign in as the account you're adding in the private window that opens.
|
|
||||||
If a private window was already open, close it first, or $site may reuse whoever it has signed in.
|
|
||||||
|
|
||||||
Don't log out of the $name CLI to do this: logging out revokes the saved sign-in.
|
|
||||||
|
|
||||||
EOF
|
|
||||||
fi
|
|
||||||
|
|
||||||
home=$(omarchy-agent-account-state begin "$provider") || fail "Couldn't set up a home for the new account."
|
|
||||||
|
|
||||||
private_browser
|
|
||||||
interruptible env BROWSER="$browser" "$home_env=$home" "${login_command[@]}"
|
|
||||||
|
|
||||||
if account=$(omarchy-agent-account-state register "$provider" "$label" "$home" 2>&1); then
|
|
||||||
home=""
|
|
||||||
added=$(jq -r '.label + (if .email != "" then " (" + .email + ")" else "" end)' <<<"$account")
|
|
||||||
omarchy-agent-usage-update --force "$provider" >/dev/null 2>&1 &
|
|
||||||
if [[ $events == "true" ]]; then
|
|
||||||
finish "Added $added."
|
|
||||||
else
|
|
||||||
finish "Added $added."
|
|
||||||
echo "Switch to it with: omarchy agent account use $provider $(jq -r .id <<<"$account")"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
home=""
|
|
||||||
fail "$account"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:summary=Run Claude, Codex, or Grok as the selected subscription account
|
|
||||||
# omarchy:args=<claude|codex|grok> [args...]
|
|
||||||
# omarchy:hidden=true
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
case "${1:-}" in
|
|
||||||
claude) account_env="CLAUDE_CONFIG_DIR" ;;
|
|
||||||
codex) account_env="CODEX_HOME" ;;
|
|
||||||
grok) account_env="GROK_HOME" ;;
|
|
||||||
*)
|
|
||||||
echo "Usage: omarchy-agent-account-exec <claude|codex|grok> [args...]" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
provider=$1
|
|
||||||
shift
|
|
||||||
|
|
||||||
# mise removes its wrapper dispatch directories before calling us, so exec
|
|
||||||
# finds the real CLI (or its first-run installer) rather than this wrapper.
|
|
||||||
# Pin the resolved home for children, including an implicit Main account.
|
|
||||||
# Explicit provider homes still take precedence.
|
|
||||||
if [[ -z ${!account_env:-} ]]; then
|
|
||||||
account_home=$(omarchy-agent-account-home "$provider")
|
|
||||||
export "OMARCHY_AGENT_${provider^^}_HOME=$account_home"
|
|
||||||
if [[ -n $account_home ]]; then
|
|
||||||
export "$account_env=$account_home"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Resolve before restoring dispatch on PATH: mise removes the wrapper paths to
|
|
||||||
# avoid recursion, but the agent's subprocesses need them for other providers.
|
|
||||||
if ! agent_binary=$(type -P "$provider"); then
|
|
||||||
# A mise dispatcher can exist before its CLI or first-run installer does.
|
|
||||||
export MISE_MINIMUM_RELEASE_AGE=0
|
|
||||||
mise use -g --quiet "$provider"
|
|
||||||
agent_binary=$(mise which "$provider")
|
|
||||||
fi
|
|
||||||
export PATH="${MISE_DATA_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/mise}/command-wrappers/bin:$PATH"
|
|
||||||
exec "$agent_binary" "$@"
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:summary=Print the config home of the active Claude, Codex, or Grok account
|
|
||||||
# omarchy:args=<claude|codex|grok>
|
|
||||||
# omarchy:hidden=true
|
|
||||||
|
|
||||||
# Prints nothing while the primary account (~/.claude, ~/.codex) is active, so
|
|
||||||
# a launch only sets CLAUDE_CONFIG_DIR / CODEX_HOME when there's somewhere
|
|
||||||
# else to point it. Without a registry, no Python starts at all: this runs on
|
|
||||||
# every `claude` and `codex` typed at a prompt.
|
|
||||||
registry="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/agents/accounts/${1:-}.json"
|
|
||||||
|
|
||||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
|
|
||||||
echo "Usage: omarchy-agent-account-home <claude|codex|grok>" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# A running session pins its home, including an empty value for Main. Claude's
|
|
||||||
# implicit default also selects ~/.claude.json, so Main must stay implicit.
|
|
||||||
account_pin="OMARCHY_AGENT_${1^^}_HOME"
|
|
||||||
if [[ -v $account_pin ]]; then
|
|
||||||
printf '%s' "${!account_pin}"
|
|
||||||
elif [[ -f $registry ]]; then
|
|
||||||
exec omarchy-agent-account-state home "$1"
|
|
||||||
fi
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:summary=List Claude, Codex, and Grok subscription accounts and their limits
|
|
||||||
# omarchy:args=[claude|codex|grok] [--json]
|
|
||||||
# omarchy:examples=omarchy agent account list | omarchy agent account list claude --json
|
|
||||||
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
provider=""
|
|
||||||
json=false
|
|
||||||
for arg in "$@"; do
|
|
||||||
case "$arg" in
|
|
||||||
claude | codex | grok) provider="$arg" ;;
|
|
||||||
--json) json=true ;;
|
|
||||||
*)
|
|
||||||
echo "Usage: omarchy-agent-account-list [claude|codex|grok] [--json]" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
usage_dir="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/agents/usage"
|
|
||||||
accounts=$(omarchy-agent-account-state list $provider) || exit 1
|
|
||||||
|
|
||||||
# Join each account with the limits its provider's usage record last saw. With
|
|
||||||
# a single account the collectors keep its limits at the record's top level.
|
|
||||||
accounts=$(jq -c --arg dir "$usage_dir" '
|
|
||||||
map(. as $p | .accounts |= map(. + {limits: []}))' <<<"$accounts")
|
|
||||||
for p in claude codex grok; do
|
|
||||||
record="$usage_dir/$p.json"
|
|
||||||
[[ -f $record ]] || continue
|
|
||||||
accounts=$(jq -c --arg p "$p" --slurpfile record "$record" '
|
|
||||||
map(if .provider == $p then
|
|
||||||
.accounts |= map(. as $a | . + {limits: (
|
|
||||||
if ($record[0].accounts // []) | length > 0 then
|
|
||||||
[$record[0].accounts[] | select(.id == $a.id) | .limits] | first // []
|
|
||||||
elif $a.primary then
|
|
||||||
$record[0].limits // []
|
|
||||||
else
|
|
||||||
[]
|
|
||||||
end)})
|
|
||||||
else . end)' <<<"$accounts")
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ $json == "true" ]]; then
|
|
||||||
echo "$accounts"
|
|
||||||
else
|
|
||||||
# A provider nobody has signed in to has nothing to list.
|
|
||||||
for (( i = 0; i < $(jq length <<<"$accounts"); i++ )); do
|
|
||||||
jq -e ".[$i].accounts | any(.signedIn)" <<<"$accounts" >/dev/null || continue
|
|
||||||
jq -r ".[$i] | \"\(.name) — \(.switch) switching at \(.threshold)%\"" <<<"$accounts"
|
|
||||||
jq -r ".[$i].accounts[] |
|
|
||||||
\"\(if .active then \"*\" else \" \" end)\t\(.id)\t\(.label)\t\(.plan)\t\(.email)\t\" +
|
|
||||||
([.limits[]? | select(.percent != null) | \"\(.label): \((.percent * 100) | round)%\"] | join(\", \"))" <<<"$accounts" |
|
|
||||||
column -t -s $'\t' | sed 's/^/ /'
|
|
||||||
echo
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:summary=Switch Claude, Codex, or Grok accounts automatically near a limit, or only notify
|
|
||||||
# omarchy:args=[claude|codex|grok] [manual|auto] [threshold]
|
|
||||||
# omarchy:examples=omarchy agent account mode auto | omarchy agent account mode auto 90 | omarchy agent account mode codex manual
|
|
||||||
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
# The provider can be left out when it's your default agent:
|
|
||||||
# `omarchy agent account use work`.
|
|
||||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
|
|
||||||
default=$(omarchy-default-agent)
|
|
||||||
[[ $default == "claude" || $default == "codex" || $default == "grok" ]] && set -- "$default" "$@"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
|
|
||||||
echo "Usage: omarchy-agent-account-mode [claude|codex|grok] [manual|auto] [threshold]" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n ${2:-} ]]; then
|
|
||||||
omarchy-agent-account-state mode "$@" || exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
omarchy-agent-account-state registry "$1" | jq -r '
|
|
||||||
if .switch == "auto" then
|
|
||||||
"\(.name) switches new sessions to the account with the most headroom at \(.threshold)%."
|
|
||||||
else
|
|
||||||
"\(.name) notifies at \(.threshold)% and switches when you say so."
|
|
||||||
end'
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:summary=Forget an added Claude, Codex, or Grok account
|
|
||||||
# omarchy:args=[claude|codex|grok] <account>
|
|
||||||
# omarchy:examples=omarchy agent account remove work | omarchy agent account remove codex side
|
|
||||||
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
# The provider can be left out when it's your default agent:
|
|
||||||
# `omarchy agent account use work`.
|
|
||||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
|
|
||||||
default=$(omarchy-default-agent)
|
|
||||||
[[ $default == "claude" || $default == "codex" || $default == "grok" ]] && set -- "$default" "$@"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]] || [[ -z ${2:-} ]]; then
|
|
||||||
echo "Usage: omarchy-agent-account-remove [claude|codex|grok] <account>" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Removing only deletes the account's home here. It never logs out, which
|
|
||||||
# would revoke the sign-in for every other copy of it too.
|
|
||||||
if [[ -t 0 ]] && ! gum confirm "Forget the $1 account $2? You'd have to sign in again to add it back."; then
|
|
||||||
exit 130
|
|
||||||
fi
|
|
||||||
|
|
||||||
account=$(omarchy-agent-account-state remove "$1" "$2") || exit 1
|
|
||||||
echo "Forgot $(jq -r .label <<<"$account")."
|
|
||||||
# The panel reads which accounts exist from the usage record.
|
|
||||||
omarchy-agent-usage-update --limits-only "$1" >/dev/null 2>&1 &
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:summary=Rename a Claude, Codex, or Grok subscription account
|
|
||||||
# omarchy:args=[claude|codex|grok] <account> <name>
|
|
||||||
# omarchy:examples=omarchy agent account rename primary Hey | omarchy agent account rename codex side Side project
|
|
||||||
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
# The provider can be left out when it's your default agent:
|
|
||||||
# `omarchy agent account rename primary Hey`.
|
|
||||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
|
|
||||||
default=$(omarchy-default-agent)
|
|
||||||
[[ $default == "claude" || $default == "codex" || $default == "grok" ]] && set -- "$default" "$@"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]] || (( $# < 3 )); then
|
|
||||||
echo "Usage: omarchy-agent-account-rename [claude|codex|grok] <account> <name>" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
account=$(omarchy-agent-account-state rename "$@") || exit 1
|
|
||||||
echo "Renamed to $(jq -r .label <<<"$account"). Switch to it with: omarchy agent account use $(jq -r .id <<<"$account")"
|
|
||||||
# The panel reads account names from the usage record.
|
|
||||||
omarchy-agent-usage-update --limits-only "$1" >/dev/null 2>&1 &
|
|
||||||
@@ -1,787 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
# omarchy:summary=Read and change the registry of Claude, Codex, and Grok subscription accounts
|
|
||||||
# omarchy:args=<registry|home|homes|list|refresh|begin|register|use|rename|remove|mode|link|autoswitch> [provider] [args...]
|
|
||||||
# omarchy:hidden=true
|
|
||||||
"""The account registry behind `omarchy agent account`.
|
|
||||||
|
|
||||||
An account is a Claude Code, Codex, or Grok login Omarchy keeps a home for.
|
|
||||||
The login you already have stays where the CLI put it (~/.claude, ~/.codex,
|
|
||||||
~/.grok) and
|
|
||||||
is the primary account; added accounts get a home of their own under
|
|
||||||
~/.local/state/omarchy/agents/accounts/<provider>/<id>/ that holds only that
|
|
||||||
account's credentials and links everything else back to the primary home, so
|
|
||||||
conversation history, settings and skills stay one set across accounts.
|
|
||||||
|
|
||||||
Each provider's registry is one JSON file beside those homes. It records which
|
|
||||||
account is active — the one new sessions start as — how switching happens, and
|
|
||||||
display-safe identity. Tokens never enter it and never leave this process.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import datetime as dt
|
|
||||||
import fcntl
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
PROVIDERS = {
|
|
||||||
"claude": {
|
|
||||||
"name": "Claude",
|
|
||||||
"home_env": "CLAUDE_CONFIG_DIR",
|
|
||||||
# Everything a session reads or writes that isn't tied to the login. A
|
|
||||||
# missing directory is created in the primary home first; a missing file
|
|
||||||
# is linked anyway, so whichever account writes it first writes it there.
|
|
||||||
"shared_dirs": ["projects", "skills", "agents", "commands", "plugins", "hooks", "themes", "output-styles", "file-history", "todos", "plans"],
|
|
||||||
"shared_files": ["settings.json", "CLAUDE.md", "history.jsonl", "keybindings.json"],
|
|
||||||
# Keys copied from the primary's ~/.claude.json into a new account's, so a
|
|
||||||
# second login keeps MCP servers, folder trust and onboarding choices.
|
|
||||||
"carried_keys": ["mcpServers", "projects", "theme", "hasCompletedOnboarding", "lastOnboardingVersion", "editorMode"],
|
|
||||||
},
|
|
||||||
"codex": {
|
|
||||||
"name": "Codex",
|
|
||||||
"home_env": "CODEX_HOME",
|
|
||||||
"shared_dirs": ["sessions", "archived_sessions", "prompts", "skills", "rules", "plugins"],
|
|
||||||
"shared_files": ["config.toml", "AGENTS.md", "history.jsonl", "hooks.json"],
|
|
||||||
"carried_keys": [],
|
|
||||||
},
|
|
||||||
"grok": {
|
|
||||||
"name": "Grok",
|
|
||||||
"home_env": "GROK_HOME",
|
|
||||||
"shared_dirs": ["sessions", "skills", "agents", "plugins", "installed-plugins", "marketplace-cache", "hooks", "memory", "memory-v2", "rules", "commands", "workflows", "personas", "worktrees"],
|
|
||||||
"shared_files": ["config.toml", "settings.json", "lsp.json", "pager.toml", "sandbox.toml", "trusted_folders.toml", "statusline.sh", "worktrees.db"],
|
|
||||||
"carried_keys": [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
DEFAULT_THRESHOLD = 95
|
|
||||||
# Ids an account can't take because something else already answers to them:
|
|
||||||
# `use next` cycles, a provider name is read as the provider when it comes
|
|
||||||
# first, and the menu's Add Account row is `.add`.
|
|
||||||
RESERVED_IDS = {"next", "add", "claude", "codex", "grok", "primary"}
|
|
||||||
SWITCH_MODES = ("manual", "auto")
|
|
||||||
|
|
||||||
|
|
||||||
class AccountError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------- paths
|
|
||||||
|
|
||||||
|
|
||||||
def state_root() -> Path:
|
|
||||||
return Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state") / "omarchy" / "agents"
|
|
||||||
|
|
||||||
|
|
||||||
def accounts_root() -> Path:
|
|
||||||
return state_root() / "accounts"
|
|
||||||
|
|
||||||
|
|
||||||
def registry_path(provider: str) -> Path:
|
|
||||||
return accounts_root() / f"{provider}.json"
|
|
||||||
|
|
||||||
|
|
||||||
def usage_record_path(provider: str) -> Path:
|
|
||||||
return state_root() / "usage" / f"{provider}.json"
|
|
||||||
|
|
||||||
|
|
||||||
# The primary home is where the CLI keeps a login when nothing tells it
|
|
||||||
# otherwise. It is deliberately not read from CLAUDE_CONFIG_DIR / CODEX_HOME /
|
|
||||||
# GROK_HOME:
|
|
||||||
# those are how an added account is selected, so honoring them here would make
|
|
||||||
# whichever account launched this command look like the primary.
|
|
||||||
def primary_home(provider: str) -> Path:
|
|
||||||
return Path.home() / f".{provider}"
|
|
||||||
|
|
||||||
|
|
||||||
def account_home(provider: str, account: dict[str, Any]) -> Path:
|
|
||||||
home = str(account.get("home") or "")
|
|
||||||
return Path(home) if home else primary_home(provider)
|
|
||||||
|
|
||||||
|
|
||||||
# Claude keeps its account file beside the config dir for the default home
|
|
||||||
# (~/.claude.json) and inside it for any other.
|
|
||||||
def claude_account_file(home: Path) -> Path:
|
|
||||||
if home == primary_home("claude"):
|
|
||||||
return Path.home() / ".claude.json"
|
|
||||||
return home / ".claude.json"
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------- identity
|
|
||||||
|
|
||||||
|
|
||||||
def read_json(path: Path) -> Any:
|
|
||||||
try:
|
|
||||||
return json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def plan_label(tier: str, subscription: str) -> str:
|
|
||||||
match = re.search(r"max_(\d+x)", tier or "", re.IGNORECASE)
|
|
||||||
if match:
|
|
||||||
return "Max " + match.group(1)
|
|
||||||
if subscription:
|
|
||||||
return subscription[0].upper() + subscription[1:]
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def jwt_claims(token: str) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
payload = token.split(".")[1]
|
|
||||||
payload += "=" * (-len(payload) % 4)
|
|
||||||
claims = json.loads(base64.urlsafe_b64decode(payload.encode("ascii")))
|
|
||||||
return claims if isinstance(claims, dict) else {}
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
# Who a home is signed in as, from files the CLI wrote at login. Only
|
|
||||||
# display-safe fields come out; an empty accountId means nobody is signed in.
|
|
||||||
def identity(provider: str, home: Path) -> dict[str, str]:
|
|
||||||
if provider == "claude":
|
|
||||||
account = (read_json(claude_account_file(home)) or {}).get("oauthAccount") or {}
|
|
||||||
login = (read_json(home / ".credentials.json") or {}).get("claudeAiOauth") or {}
|
|
||||||
return {
|
|
||||||
"accountId": str(account.get("accountUuid") or ""),
|
|
||||||
"email": str(account.get("emailAddress") or ""),
|
|
||||||
"org": str(account.get("organizationName") or ""),
|
|
||||||
"plan": plan_label(str(login.get("rateLimitTier") or ""), str(login.get("subscriptionType") or "")),
|
|
||||||
}
|
|
||||||
|
|
||||||
if provider == "grok":
|
|
||||||
return grok_identity(home)
|
|
||||||
|
|
||||||
auth = read_json(home / "auth.json") or {}
|
|
||||||
tokens = auth.get("tokens") or {}
|
|
||||||
claims = jwt_claims(str(tokens.get("id_token") or ""))
|
|
||||||
openai = claims.get("https://api.openai.com/auth") or {}
|
|
||||||
plan = str(openai.get("chatgpt_plan_type") or "")
|
|
||||||
return {
|
|
||||||
"accountId": str(tokens.get("account_id") or openai.get("chatgpt_account_id") or ""),
|
|
||||||
"email": str(claims.get("email") or ""),
|
|
||||||
"org": "",
|
|
||||||
"plan": plan[0].upper() + plan[1:] if plan else "",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Grok keeps one login per issuer in auth.json, and the plan only in the
|
|
||||||
# settings it caches from xAI, as a JSON string inside that file.
|
|
||||||
def grok_identity(home: Path) -> dict[str, str]:
|
|
||||||
auth = read_json(home / "auth.json") or {}
|
|
||||||
login = next((v for v in auth.values() if isinstance(v, dict)), {}) if isinstance(auth, dict) else {}
|
|
||||||
try:
|
|
||||||
settings = json.loads((read_json(home / "settings_cache.json") or {}).get("payload") or "{}").get("settings") or {}
|
|
||||||
except Exception:
|
|
||||||
settings = {}
|
|
||||||
return {
|
|
||||||
"accountId": str(login.get("user_id") or ""),
|
|
||||||
"email": str(login.get("email") or ""),
|
|
||||||
"org": "",
|
|
||||||
"plan": str(settings.get("subscription_tier_display") or ""),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- registry
|
|
||||||
|
|
||||||
|
|
||||||
def slug(label: str) -> str:
|
|
||||||
return re.sub(r"[^a-z0-9]+", "-", label.lower()).strip("-")
|
|
||||||
|
|
||||||
|
|
||||||
def empty_registry(provider: str) -> dict[str, Any]:
|
|
||||||
return {"active": "main", "switch": "manual", "threshold": DEFAULT_THRESHOLD, "alert": "", "accounts": []}
|
|
||||||
|
|
||||||
|
|
||||||
def primary_entry(provider: str) -> dict[str, Any]:
|
|
||||||
entry = {"id": "main", "label": "Main", "home": "", "primary": True}
|
|
||||||
entry.update(identity(provider, primary_home(provider)))
|
|
||||||
return entry
|
|
||||||
|
|
||||||
|
|
||||||
def normalized(provider: str, data: Any) -> dict[str, Any]:
|
|
||||||
registry = empty_registry(provider)
|
|
||||||
if isinstance(data, dict):
|
|
||||||
registry.update({key: data[key] for key in registry if key in data})
|
|
||||||
accounts = [a for a in registry["accounts"] if isinstance(a, dict) and a.get("id")]
|
|
||||||
if not any(a.get("primary") for a in accounts):
|
|
||||||
accounts.insert(0, primary_entry(provider))
|
|
||||||
registry["accounts"] = accounts
|
|
||||||
if registry["switch"] not in SWITCH_MODES:
|
|
||||||
registry["switch"] = "manual"
|
|
||||||
try:
|
|
||||||
registry["threshold"] = max(50, min(100, int(registry["threshold"])))
|
|
||||||
except Exception:
|
|
||||||
registry["threshold"] = DEFAULT_THRESHOLD
|
|
||||||
if not find(registry, registry["active"]):
|
|
||||||
registry["active"] = accounts[0]["id"]
|
|
||||||
return registry
|
|
||||||
|
|
||||||
|
|
||||||
def load(provider: str) -> dict[str, Any]:
|
|
||||||
return normalized(provider, read_json(registry_path(provider)))
|
|
||||||
|
|
||||||
|
|
||||||
def save(provider: str, registry: dict[str, Any]) -> None:
|
|
||||||
path = registry_path(provider)
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
os.chmod(path.parent, 0o700)
|
|
||||||
fd, tmp = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent))
|
|
||||||
try:
|
|
||||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
||||||
json.dump(registry, handle, indent=2)
|
|
||||||
handle.write("\n")
|
|
||||||
os.chmod(tmp, 0o600)
|
|
||||||
os.replace(tmp, path)
|
|
||||||
except Exception:
|
|
||||||
Path(tmp).unlink(missing_ok=True)
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
# Every change is read-modify-write under one lock per provider, so the panel,
|
|
||||||
# the autoswitch run after each usage update, and a command typed at a prompt
|
|
||||||
# can't interleave and drop each other's edits.
|
|
||||||
class Locked:
|
|
||||||
def __init__(self, provider: str):
|
|
||||||
self.provider = provider
|
|
||||||
|
|
||||||
def __enter__(self) -> dict[str, Any]:
|
|
||||||
accounts_root().mkdir(parents=True, exist_ok=True)
|
|
||||||
self.handle = open(accounts_root() / f".{self.provider}.lock", "w")
|
|
||||||
fcntl.flock(self.handle, fcntl.LOCK_EX)
|
|
||||||
self.registry = load(self.provider)
|
|
||||||
return self.registry
|
|
||||||
|
|
||||||
def __exit__(self, kind, value, traceback) -> None:
|
|
||||||
try:
|
|
||||||
if kind is None:
|
|
||||||
save(self.provider, self.registry)
|
|
||||||
finally:
|
|
||||||
fcntl.flock(self.handle, fcntl.LOCK_UN)
|
|
||||||
self.handle.close()
|
|
||||||
|
|
||||||
|
|
||||||
def find(registry: dict[str, Any], account_id: str) -> dict[str, Any] | None:
|
|
||||||
for account in registry["accounts"]:
|
|
||||||
if account.get("id") == account_id:
|
|
||||||
return account
|
|
||||||
# `primary` names the login that was there first, whatever it's called now.
|
|
||||||
if account_id == "primary":
|
|
||||||
for account in registry["accounts"]:
|
|
||||||
if account.get("primary"):
|
|
||||||
return account
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def active(registry: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
return find(registry, registry["active"]) or registry["accounts"][0]
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------- homes
|
|
||||||
|
|
||||||
|
|
||||||
# Lay the links that make an added account share everything but its login
|
|
||||||
# with the primary home. Safe to repeat: an existing link or real file is left
|
|
||||||
# as it is, so nothing an account wrote for itself is ever replaced.
|
|
||||||
def link_shared(provider: str, home: Path) -> None:
|
|
||||||
spec = PROVIDERS[provider]
|
|
||||||
primary = primary_home(provider)
|
|
||||||
home.mkdir(parents=True, exist_ok=True)
|
|
||||||
os.chmod(home, 0o700)
|
|
||||||
for name in spec["shared_dirs"]:
|
|
||||||
(primary / name).mkdir(parents=True, exist_ok=True)
|
|
||||||
for name in spec["shared_dirs"] + spec["shared_files"]:
|
|
||||||
target = home / name
|
|
||||||
if target.exists() or target.is_symlink():
|
|
||||||
continue
|
|
||||||
target.symlink_to(primary / name)
|
|
||||||
|
|
||||||
|
|
||||||
def carry_settings(provider: str, home: Path) -> None:
|
|
||||||
keys = PROVIDERS[provider]["carried_keys"]
|
|
||||||
if not keys:
|
|
||||||
return
|
|
||||||
source = read_json(claude_account_file(primary_home(provider))) or {}
|
|
||||||
path = claude_account_file(home)
|
|
||||||
data = read_json(path) or {}
|
|
||||||
for key in keys:
|
|
||||||
if key in source and key not in data:
|
|
||||||
data[key] = source[key]
|
|
||||||
fd, tmp = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent))
|
|
||||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
||||||
json.dump(data, handle, indent=2)
|
|
||||||
os.chmod(tmp, 0o600)
|
|
||||||
os.replace(tmp, path)
|
|
||||||
|
|
||||||
|
|
||||||
# A login in progress gets a scratch home beside the real ones, so an
|
|
||||||
# abandoned or duplicate login never shows up as an account.
|
|
||||||
def begin(provider: str) -> Path:
|
|
||||||
pending = accounts_root() / provider / ".pending"
|
|
||||||
pending.mkdir(parents=True, exist_ok=True)
|
|
||||||
os.chmod(pending.parent, 0o700)
|
|
||||||
home = Path(tempfile.mkdtemp(prefix="login-", dir=str(pending)))
|
|
||||||
link_shared(provider, home)
|
|
||||||
return home
|
|
||||||
|
|
||||||
|
|
||||||
def register(provider: str, label: str, pending: Path) -> dict[str, Any]:
|
|
||||||
pending = pending.resolve()
|
|
||||||
if pending.parent != (accounts_root() / provider / ".pending").resolve():
|
|
||||||
raise AccountError(f"{pending} is not a pending {PROVIDERS[provider]['name']} login")
|
|
||||||
|
|
||||||
found = identity(provider, pending)
|
|
||||||
if not found["accountId"]:
|
|
||||||
shutil.rmtree(pending, ignore_errors=True)
|
|
||||||
raise AccountError("The login didn't finish, so no account was added.")
|
|
||||||
|
|
||||||
with Locked(provider) as registry:
|
|
||||||
# Each home is asked who it is now, not trusted from when it was added: a
|
|
||||||
# `claude auth login` run by hand in ~/.claude changes the primary.
|
|
||||||
for account in registry["accounts"]:
|
|
||||||
account.update(identity(provider, account_home(provider, account)))
|
|
||||||
for account in registry["accounts"]:
|
|
||||||
if account.get("accountId") == found["accountId"]:
|
|
||||||
shutil.rmtree(pending, ignore_errors=True)
|
|
||||||
raise AccountError(
|
|
||||||
f"That's {account['label']} ({found['email'] or 'already added'}). "
|
|
||||||
"Try again, and sign in as the other account in the private window."
|
|
||||||
)
|
|
||||||
|
|
||||||
label = label.strip() or found["email"].split("@")[0] or "Account"
|
|
||||||
account_id = unique_id(provider, registry, label)
|
|
||||||
|
|
||||||
# Everything that can fail happens while the login is still pending, where
|
|
||||||
# the add command cleans it up, or is undone: a home that isn't in the
|
|
||||||
# registry would keep a sign-in nothing can manage.
|
|
||||||
carry_settings(provider, pending)
|
|
||||||
home = accounts_root() / provider / account_id
|
|
||||||
pending.rename(home)
|
|
||||||
entry = {"id": account_id, "label": label, "home": str(home), "primary": False}
|
|
||||||
entry.update(found)
|
|
||||||
registry["accounts"].append(entry)
|
|
||||||
try:
|
|
||||||
save(provider, registry)
|
|
||||||
except Exception:
|
|
||||||
registry["accounts"].pop()
|
|
||||||
home.rename(pending)
|
|
||||||
raise
|
|
||||||
return entry
|
|
||||||
|
|
||||||
|
|
||||||
# The id an account answers to, from its label. It can't clash with another
|
|
||||||
# account, a routing keyword, or a home left under that name by an account
|
|
||||||
# that has since been renamed.
|
|
||||||
def unique_id(provider: str, registry: dict[str, Any], label: str, keep: dict[str, Any] | None = None) -> str:
|
|
||||||
base = slug(label) or "account"
|
|
||||||
account_id, n = base, 2
|
|
||||||
while True:
|
|
||||||
taken = find(registry, account_id)
|
|
||||||
if account_id not in RESERVED_IDS and (taken is None or taken is keep):
|
|
||||||
if keep is not None or not (accounts_root() / provider / account_id).exists():
|
|
||||||
return account_id
|
|
||||||
account_id, n = f"{base}-{n}", n + 1
|
|
||||||
|
|
||||||
|
|
||||||
# A new label, and the id that follows from it. The home stays where it is:
|
|
||||||
# sessions already running in it hold that path.
|
|
||||||
def rename(provider: str, target: str, label: str) -> dict[str, Any]:
|
|
||||||
label = label.strip()
|
|
||||||
if not label:
|
|
||||||
raise AccountError("Give the account a name")
|
|
||||||
with Locked(provider) as registry:
|
|
||||||
account = find(registry, target)
|
|
||||||
if not account:
|
|
||||||
raise AccountError(f"No {PROVIDERS[provider]['name']} account named {target}")
|
|
||||||
new_id = unique_id(provider, registry, label, keep=account)
|
|
||||||
if not account.get("primary") and not account.get("home"):
|
|
||||||
account["home"] = str(account_home(provider, account))
|
|
||||||
if registry["active"] == account["id"]:
|
|
||||||
registry["active"] = new_id
|
|
||||||
registry["alert"] = ""
|
|
||||||
account["id"] = new_id
|
|
||||||
account["label"] = label
|
|
||||||
return dict(account)
|
|
||||||
|
|
||||||
|
|
||||||
# Save who each home is signed in as now. A login run in a home, through the
|
|
||||||
# panel or the CLI, changes it, and the usage records take their names and
|
|
||||||
# emails from here.
|
|
||||||
def refresh(provider: str) -> None:
|
|
||||||
with Locked(provider) as registry:
|
|
||||||
for account in registry["accounts"]:
|
|
||||||
account.update(identity(provider, account_home(provider, account)))
|
|
||||||
|
|
||||||
|
|
||||||
def use(provider: str, target: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
||||||
with Locked(provider) as registry:
|
|
||||||
previous = active(registry)
|
|
||||||
if target == "next":
|
|
||||||
ids = [a["id"] for a in registry["accounts"]]
|
|
||||||
target = ids[(ids.index(previous["id"]) + 1) % len(ids)]
|
|
||||||
account = find(registry, target)
|
|
||||||
if not account:
|
|
||||||
raise AccountError(f"No {PROVIDERS[provider]['name']} account named {target}")
|
|
||||||
registry["active"] = account["id"]
|
|
||||||
registry["alert"] = ""
|
|
||||||
if not account.get("primary"):
|
|
||||||
link_shared(provider, account_home(provider, account))
|
|
||||||
return previous, account
|
|
||||||
|
|
||||||
|
|
||||||
# Whether a running session was started in this home. Removing it would pull
|
|
||||||
# the login out from under that session, which must never be touched.
|
|
||||||
def home_in_use(provider: str, home: Path) -> bool:
|
|
||||||
wanted = f"{PROVIDERS[provider]['home_env']}=".encode()
|
|
||||||
resolved = home.resolve()
|
|
||||||
for environ in Path("/proc").glob("[0-9]*/environ"):
|
|
||||||
try:
|
|
||||||
entries = environ.read_bytes().split(b"\0")
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
for entry in entries:
|
|
||||||
if entry.startswith(wanted) and Path(entry[len(wanted):].decode(errors="replace")).resolve() == resolved:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def remove(provider: str, target: str) -> dict[str, Any]:
|
|
||||||
with Locked(provider) as registry:
|
|
||||||
account = find(registry, target)
|
|
||||||
if not account:
|
|
||||||
raise AccountError(f"No {PROVIDERS[provider]['name']} account named {target}")
|
|
||||||
if account.get("primary"):
|
|
||||||
raise AccountError(f"{account['label']} is the login in {primary_home(provider)}; it can't be removed here.")
|
|
||||||
home = account_home(provider, account)
|
|
||||||
if home_in_use(provider, home):
|
|
||||||
raise AccountError(f"A running {PROVIDERS[provider]['name']} session uses {account['label']}. Quit it first, then remove the account.")
|
|
||||||
# Only the account's own directory goes. Its links point into the primary
|
|
||||||
# home, and rmtree removes a link without following it.
|
|
||||||
if home.resolve().parent == (accounts_root() / provider).resolve():
|
|
||||||
shutil.rmtree(home, ignore_errors=True)
|
|
||||||
registry["accounts"] = [a for a in registry["accounts"] if a["id"] != account["id"]]
|
|
||||||
if registry["active"] == account["id"]:
|
|
||||||
registry["active"] = registry["accounts"][0]["id"]
|
|
||||||
return account
|
|
||||||
|
|
||||||
|
|
||||||
def set_mode(provider: str, mode: str, threshold: str | None) -> dict[str, Any]:
|
|
||||||
if mode not in SWITCH_MODES:
|
|
||||||
raise AccountError("Switch mode is manual or auto")
|
|
||||||
with Locked(provider) as registry:
|
|
||||||
registry["switch"] = mode
|
|
||||||
if threshold is not None:
|
|
||||||
try:
|
|
||||||
registry["threshold"] = max(50, min(100, int(threshold)))
|
|
||||||
except ValueError:
|
|
||||||
raise AccountError("The threshold is a percentage between 50 and 100")
|
|
||||||
registry["alert"] = ""
|
|
||||||
return dict(registry)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------- autoswitch
|
|
||||||
|
|
||||||
|
|
||||||
def window_open(limit: dict[str, Any], now: dt.datetime) -> bool:
|
|
||||||
raw = str(limit.get("resetsAt") or "")
|
|
||||||
if not 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
|
|
||||||
|
|
||||||
|
|
||||||
# How spent an account is: its fullest window that hasn't reset yet, in
|
|
||||||
# percent. A window whose reset has passed counts as empty even when the
|
|
||||||
# numbers are stale, since that's exactly what a reset means. None when
|
|
||||||
# nothing is known at all.
|
|
||||||
def peak(limits: Any, now: dt.datetime) -> float | None:
|
|
||||||
if not isinstance(limits, list):
|
|
||||||
return None
|
|
||||||
known = [l for l in limits if isinstance(l, dict) and isinstance(l.get("percent"), (int, float))]
|
|
||||||
if not known:
|
|
||||||
return None
|
|
||||||
open_windows = [float(l["percent"]) * 100 for l in known if window_open(l, now)]
|
|
||||||
return max(open_windows) if open_windows else 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def next_reset(limits: Any, now: dt.datetime) -> dt.datetime | None:
|
|
||||||
times = []
|
|
||||||
for limit in limits if isinstance(limits, list) else []:
|
|
||||||
try:
|
|
||||||
when = dt.datetime.fromisoformat(str(limit.get("resetsAt") or "").replace("Z", "+00:00"))
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
if when.tzinfo is None:
|
|
||||||
when = when.replace(tzinfo=dt.timezone.utc)
|
|
||||||
if when > now:
|
|
||||||
times.append(when)
|
|
||||||
return min(times) if times else None
|
|
||||||
|
|
||||||
|
|
||||||
# When an account over the threshold drops back under it: once every window
|
|
||||||
# holding it there has reset, which is the latest of those resets. A 10%
|
|
||||||
# session resetting in an hour doesn't help an account whose weekly window is
|
|
||||||
# full for six more days. None when a blocking window never says.
|
|
||||||
def available_at(limits: Any, threshold: float, now: dt.datetime) -> dt.datetime | None:
|
|
||||||
latest = None
|
|
||||||
for limit in limits if isinstance(limits, list) else []:
|
|
||||||
if not isinstance(limit, dict) or not isinstance(limit.get("percent"), (int, float)):
|
|
||||||
continue
|
|
||||||
if float(limit["percent"]) * 100 < threshold or not window_open(limit, now):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
when = dt.datetime.fromisoformat(str(limit.get("resetsAt") or "").replace("Z", "+00:00"))
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
if when.tzinfo is None:
|
|
||||||
when = when.replace(tzinfo=dt.timezone.utc)
|
|
||||||
latest = when if latest is None or when > latest else latest
|
|
||||||
return latest
|
|
||||||
|
|
||||||
|
|
||||||
def duration(delta: dt.timedelta) -> str:
|
|
||||||
minutes = max(1, int(delta.total_seconds() // 60))
|
|
||||||
hours, minutes = divmod(minutes, 60)
|
|
||||||
days, hours = divmod(hours, 24)
|
|
||||||
if days:
|
|
||||||
return f"{days}d {hours}h"
|
|
||||||
if hours:
|
|
||||||
return f"{hours}h {minutes}m"
|
|
||||||
return f"{minutes}m"
|
|
||||||
|
|
||||||
|
|
||||||
# The whole switching policy, with no I/O: given the registry and the per
|
|
||||||
# account limits from the usage record, what should happen. Returns None when
|
|
||||||
# nothing should, or {"action": "switch"|"notify"|"exhausted", ...}.
|
|
||||||
#
|
|
||||||
# Only the active account crossing the threshold starts anything, so auto
|
|
||||||
# mode never flaps back to an account that just reset. `alert` remembers the
|
|
||||||
# last thing said, so a shell restart or the next refresh doesn't repeat it.
|
|
||||||
#
|
|
||||||
# Numbers kept from an earlier check may be out of date, but a parked account's
|
|
||||||
# sign-in lapses within hours and can't be checked again until a session runs
|
|
||||||
# in it. So a stale account is still a candidate: among accounts with room, one
|
|
||||||
# checked just now wins, but room comes first. An account within 15 points of
|
|
||||||
# the threshold (where the panel starts checking more often) is near its limit
|
|
||||||
# however fresh its numbers are.
|
|
||||||
def decide(registry: dict[str, Any], usage: dict[str, Any], now: dt.datetime, stale: frozenset[str] = frozenset()) -> dict[str, Any] | None:
|
|
||||||
threshold = float(registry["threshold"])
|
|
||||||
current = active(registry)
|
|
||||||
current_peak = peak(usage.get(current["id"]), now)
|
|
||||||
if current_peak is None or current_peak < threshold:
|
|
||||||
return {"action": "clear"} if registry.get("alert") else None
|
|
||||||
|
|
||||||
candidates = []
|
|
||||||
for account in registry["accounts"]:
|
|
||||||
if account["id"] == current["id"]:
|
|
||||||
continue
|
|
||||||
level = peak(usage.get(account["id"]), now)
|
|
||||||
if level is None or level >= threshold:
|
|
||||||
continue
|
|
||||||
reset = next_reset(usage.get(account["id"]), now) or dt.datetime.max.replace(tzinfo=dt.timezone.utc)
|
|
||||||
candidates.append((level >= threshold - 15, account["id"] in stale, level, reset, account))
|
|
||||||
candidates.sort(key=lambda c: c[:4])
|
|
||||||
|
|
||||||
if candidates:
|
|
||||||
_, _, level, _, target = candidates[0]
|
|
||||||
action = "switch" if registry["switch"] == "auto" else "notify"
|
|
||||||
alert = f"{action}:{current['id']}:{target['id']}"
|
|
||||||
if registry.get("alert") == alert:
|
|
||||||
return None
|
|
||||||
return {"action": action, "alert": alert, "from": current, "to": target, "fromPeak": current_peak, "toPeak": level}
|
|
||||||
|
|
||||||
alert = f"exhausted:{current['id']}"
|
|
||||||
if registry.get("alert") == alert or len(registry["accounts"]) < 2:
|
|
||||||
return None
|
|
||||||
# Only when every account is known to be over. One that's signed out or
|
|
||||||
# couldn't be checked might have room, so there's nothing honest to say.
|
|
||||||
if any(peak(usage.get(a["id"]), now) is None for a in registry["accounts"]):
|
|
||||||
return None
|
|
||||||
soonest = None
|
|
||||||
for account in registry["accounts"]:
|
|
||||||
reset = available_at(usage.get(account["id"]), threshold, now)
|
|
||||||
if reset and (soonest is None or reset < soonest[0]):
|
|
||||||
soonest = (reset, account)
|
|
||||||
return {"action": "exhausted", "alert": alert, "from": current, "fromPeak": current_peak, "soonest": soonest}
|
|
||||||
|
|
||||||
|
|
||||||
# Limits per account id. An account nobody is signed in to is left out, so it
|
|
||||||
# is never a candidate: its cached headroom is no use to a session that can't
|
|
||||||
# start. One whose token merely expired stays in, since a session refreshes it.
|
|
||||||
def usage_by_account(provider: str) -> tuple[dict[str, Any], frozenset[str]]:
|
|
||||||
record = read_json(usage_record_path(provider)) or {}
|
|
||||||
accounts = [
|
|
||||||
a for a in record.get("accounts") or []
|
|
||||||
if isinstance(a, dict) and a.get("id") and a.get("usageStatusText") != "Waiting for auth"
|
|
||||||
]
|
|
||||||
return (
|
|
||||||
{str(a["id"]): a.get("limits") for a in accounts},
|
|
||||||
frozenset(str(a["id"]) for a in accounts if a.get("stale") is True),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def notify(title: str, body: str, action: list[str] | None = None) -> None:
|
|
||||||
command = ["omarchy-notification-send", "--app-name", "Omarchy Agents", "-g", "", title, body]
|
|
||||||
if action:
|
|
||||||
command += ["--exec"] + action
|
|
||||||
subprocess.run(command, check=False)
|
|
||||||
|
|
||||||
|
|
||||||
# Returns True when the active account changed.
|
|
||||||
def autoswitch(provider: str, now: dt.datetime | None = None) -> bool:
|
|
||||||
now = now or dt.datetime.now(dt.timezone.utc)
|
|
||||||
name = PROVIDERS[provider]["name"]
|
|
||||||
with Locked(provider) as registry:
|
|
||||||
if len(registry["accounts"]) < 2:
|
|
||||||
return False
|
|
||||||
usage, stale = usage_by_account(provider)
|
|
||||||
decision = decide(registry, usage, now, stale)
|
|
||||||
if decision is None:
|
|
||||||
return False
|
|
||||||
if decision["action"] == "clear":
|
|
||||||
registry["alert"] = ""
|
|
||||||
return False
|
|
||||||
|
|
||||||
registry["alert"] = decision["alert"]
|
|
||||||
source = decision["from"]
|
|
||||||
threshold = registry["threshold"]
|
|
||||||
if decision["action"] == "exhausted":
|
|
||||||
soonest = decision["soonest"]
|
|
||||||
when = f" {soonest[1]['label']} resets in {duration(soonest[0] - now)}." if soonest else ""
|
|
||||||
notify(f"All {name} accounts are over {threshold}%", f"Staying on {source['label']}.{when}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
target = decision["to"]
|
|
||||||
headline = f"{source['label']} is at {round(decision['fromPeak'])}% of a {name} limit"
|
|
||||||
if decision["action"] == "switch":
|
|
||||||
registry["active"] = target["id"]
|
|
||||||
if not target.get("primary"):
|
|
||||||
link_shared(provider, account_home(provider, target))
|
|
||||||
notify(headline, f"New {name} sessions now use {target['label']} ({round(decision['toPeak'])}%). Running sessions stay on {source['label']}.")
|
|
||||||
return True
|
|
||||||
notify(
|
|
||||||
headline,
|
|
||||||
f"{target['label']} is at {round(decision['toPeak'])}%. Click to switch new sessions to it.",
|
|
||||||
["omarchy-agent-account-use", provider, target["id"]],
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------- CLI
|
|
||||||
|
|
||||||
|
|
||||||
def provider_arg(args: list[str]) -> str:
|
|
||||||
if not args or args[0] not in PROVIDERS:
|
|
||||||
raise AccountError("Name a provider: claude, codex, or grok")
|
|
||||||
return args[0]
|
|
||||||
|
|
||||||
|
|
||||||
def summary(provider: str, registry: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
# Identity is re-read from each home rather than trusted from the registry:
|
|
||||||
# a `claude auth login` run by hand in a home changes who it is.
|
|
||||||
for account in registry["accounts"]:
|
|
||||||
account.update(identity(provider, account_home(provider, account)))
|
|
||||||
return {
|
|
||||||
"provider": provider,
|
|
||||||
"name": PROVIDERS[provider]["name"],
|
|
||||||
"active": registry["active"],
|
|
||||||
"switch": registry["switch"],
|
|
||||||
"threshold": registry["threshold"],
|
|
||||||
"accounts": [
|
|
||||||
{key: account.get(key, "") for key in ("id", "label", "email", "org", "plan", "primary")}
|
|
||||||
| {
|
|
||||||
"home": str(account_home(provider, account)),
|
|
||||||
"active": account["id"] == registry["active"],
|
|
||||||
"signedIn": bool(account.get("accountId")),
|
|
||||||
}
|
|
||||||
for account in registry["accounts"]
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str]) -> int:
|
|
||||||
if not argv:
|
|
||||||
print(__doc__.strip().splitlines()[0], file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
command, args = argv[0], argv[1:]
|
|
||||||
try:
|
|
||||||
if command == "registry":
|
|
||||||
provider = provider_arg(args)
|
|
||||||
print(json.dumps(summary(provider, load(provider))))
|
|
||||||
elif command == "list":
|
|
||||||
providers = [provider_arg(args)] if args else list(PROVIDERS)
|
|
||||||
print(json.dumps([summary(p, load(p)) for p in providers]))
|
|
||||||
elif command == "home":
|
|
||||||
# Empty for the primary, so launching it needs no environment at all.
|
|
||||||
provider = provider_arg(args)
|
|
||||||
registry = read_json(registry_path(provider))
|
|
||||||
if registry:
|
|
||||||
account = active(normalized(provider, registry))
|
|
||||||
if not account.get("primary"):
|
|
||||||
print(account_home(provider, account))
|
|
||||||
elif command == "homes":
|
|
||||||
provider = provider_arg(args)
|
|
||||||
registry = load(provider)
|
|
||||||
for account in registry["accounts"]:
|
|
||||||
print("\t".join([account["id"], str(account_home(provider, account)), "1" if account["id"] == registry["active"] else "0"]))
|
|
||||||
elif command == "refresh":
|
|
||||||
for provider in ([provider_arg(args)] if args else list(PROVIDERS)):
|
|
||||||
if registry_path(provider).exists():
|
|
||||||
refresh(provider)
|
|
||||||
elif command == "begin":
|
|
||||||
print(begin(provider_arg(args)))
|
|
||||||
elif command == "register":
|
|
||||||
provider = provider_arg(args)
|
|
||||||
if len(args) < 3:
|
|
||||||
raise AccountError("register <provider> <label> <pending-home>")
|
|
||||||
print(json.dumps(register(provider, args[1], Path(args[2]))))
|
|
||||||
elif command == "use":
|
|
||||||
provider = provider_arg(args)
|
|
||||||
previous, account = use(provider, args[1] if len(args) > 1 else "next")
|
|
||||||
print(json.dumps({"previous": previous, "active": account}))
|
|
||||||
elif command == "remove":
|
|
||||||
provider = provider_arg(args)
|
|
||||||
if len(args) < 2:
|
|
||||||
raise AccountError("remove <provider> <account>")
|
|
||||||
print(json.dumps(remove(provider, args[1])))
|
|
||||||
elif command == "rename":
|
|
||||||
provider = provider_arg(args)
|
|
||||||
if len(args) < 3:
|
|
||||||
raise AccountError("rename <provider> <account> <label>")
|
|
||||||
print(json.dumps(rename(provider, args[1], " ".join(args[2:]))))
|
|
||||||
elif command == "mode":
|
|
||||||
provider = provider_arg(args)
|
|
||||||
if len(args) < 2:
|
|
||||||
raise AccountError("mode <provider> <manual|auto> [threshold]")
|
|
||||||
set_mode(provider, args[1], args[2] if len(args) > 2 else None)
|
|
||||||
elif command == "link":
|
|
||||||
provider = provider_arg(args)
|
|
||||||
link_shared(provider, Path(args[1]))
|
|
||||||
elif command == "autoswitch":
|
|
||||||
# Prints each provider whose active account changed, so the caller
|
|
||||||
# knows which usage records to collect again.
|
|
||||||
for provider in ([provider_arg(args)] if args else list(PROVIDERS)):
|
|
||||||
if registry_path(provider).exists() and autoswitch(provider):
|
|
||||||
print(provider)
|
|
||||||
else:
|
|
||||||
raise AccountError(f"Unknown command: {command}")
|
|
||||||
except AccountError as error:
|
|
||||||
print(error, file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main(sys.argv[1:]))
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:summary=Choose which Claude, Codex, or Grok account new sessions use
|
|
||||||
# omarchy:args=[claude|codex|grok] [account|next]
|
|
||||||
# omarchy:examples=omarchy agent account use work | omarchy agent account use main | omarchy agent account use codex next
|
|
||||||
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
# The provider can be left out when it's your default agent:
|
|
||||||
# `omarchy agent account use work`.
|
|
||||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
|
|
||||||
default=$(omarchy-default-agent)
|
|
||||||
[[ $default == "claude" || $default == "codex" || $default == "grok" ]] && set -- "$default" "$@"
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "${1:-}" in
|
|
||||||
claude) name="Claude" ;;
|
|
||||||
codex) name="Codex" ;;
|
|
||||||
grok) name="Grok" ;;
|
|
||||||
*)
|
|
||||||
echo "Usage: omarchy-agent-account-use [claude|codex|grok] [account|next]" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
result=$(omarchy-agent-account-state use "$1" "${2:-next}") || exit 1
|
|
||||||
|
|
||||||
previous=$(jq -r .previous.label <<<"$result")
|
|
||||||
active=$(jq -r '.active.label + (if (.active.plan // "") != "" then " (" + .active.plan + ")" else "" end)' <<<"$result")
|
|
||||||
|
|
||||||
if [[ $(jq -r .previous.id <<<"$result") == "$(jq -r .active.id <<<"$result")" ]]; then
|
|
||||||
echo "New $name sessions already use $active."
|
|
||||||
else
|
|
||||||
message="Running sessions stay on $previous."
|
|
||||||
echo "New $name sessions now use $active. $message"
|
|
||||||
omarchy-notification-send -g "New $name sessions now use $active" "$message"
|
|
||||||
# The usage record's own limits describe the active account.
|
|
||||||
omarchy-agent-usage-update --limits-only "$1" >/dev/null 2>&1 &
|
|
||||||
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,558 +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 pi or 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 pi_auth_path() -> Path:
|
|
||||||
agent_dir = Path(os.environ.get("PI_CODING_AGENT_DIR") or (Path.home() / ".pi" / "agent"))
|
|
||||||
return agent_dir / "auth.json"
|
|
||||||
|
|
||||||
|
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
PI_ENV_REFERENCE = re.compile(r"\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))")
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_pi_key(raw: Any) -> str:
|
|
||||||
# pi credential keys are literals, $ENV_VAR/${ENV_VAR} references, or
|
|
||||||
# !command shell lookups. The collector resolves the first two; command
|
|
||||||
# lookups stay pi-only and are skipped rather than sent to the API verbatim.
|
|
||||||
key = str(raw or "").strip()
|
|
||||||
if not key or key.startswith("!"):
|
|
||||||
return ""
|
|
||||||
# Escapes survive interpolation as sentinels, then restore.
|
|
||||||
key = key.replace("$$", "\x00").replace("$!", "\x01")
|
|
||||||
|
|
||||||
unresolved = False
|
|
||||||
|
|
||||||
def substitute(match: re.Match[str]) -> str:
|
|
||||||
nonlocal unresolved
|
|
||||||
value = os.environ.get(match.group(1) or match.group(2), "")
|
|
||||||
if not value:
|
|
||||||
unresolved = True
|
|
||||||
return value
|
|
||||||
|
|
||||||
key = PI_ENV_REFERENCE.sub(substitute, key)
|
|
||||||
if unresolved:
|
|
||||||
return ""
|
|
||||||
return key.replace("\x00", "$").replace("\x01", "!")
|
|
||||||
|
|
||||||
|
|
||||||
def read_pi_key(path: Path) -> str:
|
|
||||||
try:
|
|
||||||
parsed = json.loads(path.read_text())
|
|
||||||
except (OSError, json.JSONDecodeError):
|
|
||||||
return ""
|
|
||||||
entry = parsed.get("fireworks") if isinstance(parsed, dict) else None
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
return ""
|
|
||||||
if entry.get("type") not in (None, "api_key"):
|
|
||||||
return ""
|
|
||||||
return resolve_pi_key(entry.get("key"))
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
# Agent logins are the fallback ladder below an explicit key or a firectl
|
|
||||||
# login: pi first as Omarchy's default agent, opencode as the last resort.
|
|
||||||
api_key = (
|
|
||||||
str(os.environ.get("FIREWORKS_API_KEY", "")).strip()
|
|
||||||
or file_key
|
|
||||||
or read_pi_key(pi_auth_path())
|
|
||||||
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,361 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
# omarchy:summary=Print the Grok usage record as JSON
|
|
||||||
# omarchy:args=[--force] [--limits-only]
|
|
||||||
# omarchy:hidden=true
|
|
||||||
"""Collect Grok usage into one display-ready JSON record.
|
|
||||||
|
|
||||||
The plan comes from the settings Grok caches from xAI, and the limit from the
|
|
||||||
credits endpoint the Grok CLI itself reads for its /usage view, asked with
|
|
||||||
each account's own sign-in. Sessions come from the summary Grok keeps beside
|
|
||||||
each session, and tokens and prompts from its usage.json, the ledger `grok
|
|
||||||
usage` prints: one entry per finished turn, with when it ended and its tokens
|
|
||||||
by model. The agents panel only ever reads the JSON this prints.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
AGENT_ID = "grok"
|
|
||||||
AGENT_NAME = "Grok"
|
|
||||||
AUTH_HELP = "Start Grok, or run `grok login`, to sign in."
|
|
||||||
CREDITS_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits"
|
|
||||||
# Grok's own CLI watches its subscription once a minute; a panel opened and
|
|
||||||
# shut repeatedly reuses an answer for about that long.
|
|
||||||
PROBE_REUSE_SECONDS = 60
|
|
||||||
# Grok's docs give a saved sign-in 30 days; a refresh token older than that
|
|
||||||
# can't renew the access token, so it no longer counts as signed in.
|
|
||||||
REFRESH_LIFETIME_SECONDS = 30 * 24 * 3600
|
|
||||||
# A limits-only refresh needs fresh limits, not a fresh history scan.
|
|
||||||
LIMITS_ONLY_SCAN_REUSE_SECONDS = 900
|
|
||||||
|
|
||||||
|
|
||||||
def read_json(path):
|
|
||||||
try:
|
|
||||||
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# A temporary file of its own per write, so overlapping runs can't rename
|
|
||||||
# each other's away.
|
|
||||||
def write_json(path, payload):
|
|
||||||
path = Path(path)
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
fd, tmp = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent))
|
|
||||||
try:
|
|
||||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
||||||
json.dump(payload, handle)
|
|
||||||
os.replace(tmp, path)
|
|
||||||
except Exception:
|
|
||||||
Path(tmp).unlink(missing_ok=True)
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def cache_root():
|
|
||||||
return Path(os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")) / "omarchy" / "agent-usage"
|
|
||||||
|
|
||||||
|
|
||||||
def parse_time(value):
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(str(value or "").replace("Z", "+00:00"))
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
# The home Grok uses when nothing selects an account: GROK_HOME when set,
|
|
||||||
# as the CLI itself reads it, else ~/.grok.
|
|
||||||
def default_home():
|
|
||||||
return Path(os.environ.get("GROK_HOME") or (Path.home() / ".grok"))
|
|
||||||
|
|
||||||
|
|
||||||
# Every session under <home>/sessions/<folder>/<session>/: its summary says
|
|
||||||
# when it was last active and how many prompts it had, and its usage.json
|
|
||||||
# lists each finished turn with its tokens by model. Added accounts link
|
|
||||||
# their sessions to the primary's, so this covers all of them. Grok counts
|
|
||||||
# cached input inside inputTokens; the record keeps the two apart, as the
|
|
||||||
# other collectors do.
|
|
||||||
def local_stats(home):
|
|
||||||
now = datetime.now().astimezone()
|
|
||||||
today = now.date().isoformat()
|
|
||||||
week = [(now.date() - timedelta(days=offset)).isoformat() for offset in range(6, -1, -1)]
|
|
||||||
prompts = sessions = today_sessions = today_prompts = today_tokens = 0
|
|
||||||
days = set()
|
|
||||||
by_day = {day: 0 for day in week}
|
|
||||||
today_by_model = {}
|
|
||||||
models = {}
|
|
||||||
for session in (Path(home) / "sessions").glob("*/*/"):
|
|
||||||
summary = read_json(session / "summary.json")
|
|
||||||
if isinstance(summary, dict):
|
|
||||||
active = parse_time(summary.get("last_active_at"))
|
|
||||||
if active:
|
|
||||||
day = active.astimezone().date().isoformat()
|
|
||||||
sessions += 1
|
|
||||||
prompts += int(summary.get("num_messages") or 0)
|
|
||||||
days.add(day)
|
|
||||||
if day == today:
|
|
||||||
today_sessions += 1
|
|
||||||
|
|
||||||
usage = read_json(session / "usage.json")
|
|
||||||
turns = usage.get("turns") if isinstance(usage, dict) else None
|
|
||||||
for turn in turns if isinstance(turns, list) else []:
|
|
||||||
ended = parse_time(turn.get("endedAt")) if isinstance(turn, dict) else None
|
|
||||||
if not ended:
|
|
||||||
continue
|
|
||||||
day = ended.astimezone().date().isoformat()
|
|
||||||
days.add(day)
|
|
||||||
if day == today:
|
|
||||||
today_prompts += 1
|
|
||||||
for model, bucket in (turn.get("modelUsage") or {}).items():
|
|
||||||
if not isinstance(bucket, dict):
|
|
||||||
continue
|
|
||||||
# Grok's totalTokens leaves cache writes out; counting them here keeps
|
|
||||||
# the day's total equal to the sum of its model's buckets.
|
|
||||||
cached = int(bucket.get("cachedReadTokens") or 0)
|
|
||||||
uncached = max(0, int(bucket.get("inputTokens") or 0) - cached)
|
|
||||||
output = int(bucket.get("outputTokens") or 0)
|
|
||||||
created = int(bucket.get("cacheCreationTokens") or 0)
|
|
||||||
tokens = uncached + cached + output + created
|
|
||||||
totals = models.setdefault(model, {"inputTokens": 0, "outputTokens": 0, "cacheReadInputTokens": 0, "cacheCreationInputTokens": 0})
|
|
||||||
totals["inputTokens"] += uncached
|
|
||||||
totals["outputTokens"] += output
|
|
||||||
totals["cacheReadInputTokens"] += cached
|
|
||||||
totals["cacheCreationInputTokens"] += created
|
|
||||||
if day in by_day:
|
|
||||||
by_day[day] += tokens
|
|
||||||
if day == today:
|
|
||||||
today_tokens += tokens
|
|
||||||
today_by_model[model] = today_by_model.get(model, 0) + tokens
|
|
||||||
|
|
||||||
return {
|
|
||||||
"hasLocalStats": sessions > 0,
|
|
||||||
"todayPrompts": today_prompts,
|
|
||||||
"todaySessions": today_sessions,
|
|
||||||
"todayTotalTokens": today_tokens,
|
|
||||||
"todayTokensByModel": today_by_model,
|
|
||||||
"recentDays": [{"date": day, "messageCount": by_day[day]} for day in week],
|
|
||||||
"totalPrompts": prompts,
|
|
||||||
"totalSessions": sessions,
|
|
||||||
"activeDays": len(days),
|
|
||||||
"activeDates": sorted(days),
|
|
||||||
"modelUsage": models,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# A scan from before midnight would count yesterday's sessions as today's.
|
|
||||||
def cached_local_stats(home, max_age):
|
|
||||||
cache = cache_root() / "grok-stats.json"
|
|
||||||
cached = read_json(cache) or {}
|
|
||||||
today = datetime.now().astimezone().date().isoformat()
|
|
||||||
if cached.get("home") == str(home) and cached.get("day") == today and time.time() - float(cached.get("at") or 0) < max_age:
|
|
||||||
return cached["stats"]
|
|
||||||
stats = local_stats(home)
|
|
||||||
write_json(cache, {"home": str(home), "day": today, "at": time.time(), "stats": stats})
|
|
||||||
return stats
|
|
||||||
|
|
||||||
|
|
||||||
# One login per issuer in auth.json; the first is the CLI's own.
|
|
||||||
def login(home):
|
|
||||||
auth = read_json(Path(home) / "auth.json")
|
|
||||||
if not isinstance(auth, dict):
|
|
||||||
return {}
|
|
||||||
return next((v for v in auth.values() if isinstance(v, dict)), {})
|
|
||||||
|
|
||||||
|
|
||||||
def plan(home):
|
|
||||||
cached = read_json(Path(home) / "settings_cache.json") or {}
|
|
||||||
try:
|
|
||||||
settings = json.loads(cached.get("payload") or "{}").get("settings") or {}
|
|
||||||
except Exception:
|
|
||||||
return ""
|
|
||||||
return str(settings.get("subscription_tier_display") or "")
|
|
||||||
|
|
||||||
|
|
||||||
# The credits answer as one limit window: how much of this period's included
|
|
||||||
# usage is gone, and when the period ends. The answer is protobuf JSON, which
|
|
||||||
# leaves out a field holding zero, so a period with nothing used yet has no
|
|
||||||
# percentage at all.
|
|
||||||
def credits_limit(data):
|
|
||||||
config = data["config"]
|
|
||||||
period = config["currentPeriod"]
|
|
||||||
return {
|
|
||||||
"label": "Weekly" if "WEEK" in str(period.get("type")) else "Monthly",
|
|
||||||
"percent": max(0.0, min(1.0, float(config.get("creditUsagePercent") or 0) / 100)),
|
|
||||||
"resetsAt": parse_time(period["end"]).isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# A window whose reset has passed starts over empty. A weekly one's next
|
|
||||||
# reset is a whole number of weeks later; a monthly one's isn't known.
|
|
||||||
def rolled_forward(limits):
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
rolled = []
|
|
||||||
for limit in limits:
|
|
||||||
resets = parse_time(limit.get("resetsAt"))
|
|
||||||
if resets and resets <= now:
|
|
||||||
limit = dict(limit, percent=0.0, resetsAt="")
|
|
||||||
if limit.get("label") == "Weekly":
|
|
||||||
weeks = (now - resets) // timedelta(weeks=1) + 1
|
|
||||||
limit["resetsAt"] = (resets + weeks * timedelta(weeks=1)).isoformat()
|
|
||||||
rolled.append(limit)
|
|
||||||
return rolled
|
|
||||||
|
|
||||||
|
|
||||||
def collect_limits(home, key, force):
|
|
||||||
result = {"limits": [], "usageStatusText": "", "authHelpText": AUTH_HELP, "live": False, "fetchedAtMs": 0}
|
|
||||||
entry = login(home)
|
|
||||||
token = str(entry.get("key") or "")
|
|
||||||
if not token:
|
|
||||||
result["usageStatusText"] = "Waiting for auth"
|
|
||||||
return result
|
|
||||||
|
|
||||||
cache = cache_root() / f"grok-limits-{key}.json"
|
|
||||||
cached = read_json(cache) or {}
|
|
||||||
fetched_at = float(cached.get("fetchedAtMs") or 0)
|
|
||||||
result["fetchedAtMs"] = fetched_at
|
|
||||||
|
|
||||||
expires = parse_time(entry.get("expires_at"))
|
|
||||||
if expires and expires.timestamp() <= time.time():
|
|
||||||
result["limits"] = cached.get("limits") or []
|
|
||||||
# The access token lives six hours and Grok mints a new one from its
|
|
||||||
# refresh token whenever it starts, so a lapsed one is routine, not a
|
|
||||||
# sign-out. And while Grok hasn't run, nothing here has spent its
|
|
||||||
# allowance, so the last numbers still stand, rolled past any reset.
|
|
||||||
if entry.get("refresh_token") and time.time() - expires.timestamp() < REFRESH_LIFETIME_SECONDS:
|
|
||||||
result.update(limits=rolled_forward(result["limits"]), live=True)
|
|
||||||
if not result["limits"]:
|
|
||||||
result["usageStatusText"] = "Limits paused"
|
|
||||||
result["authHelpText"] = "Start Grok to see its limits; signing in again is not needed."
|
|
||||||
else:
|
|
||||||
result["usageStatusText"] = "Sign-in expired"
|
|
||||||
result["authHelpText"] = "Grok's saved sign-in expired. Start Grok to refresh it."
|
|
||||||
return result
|
|
||||||
|
|
||||||
if cached.get("limits") and not force and time.time() - fetched_at / 1000 < PROBE_REUSE_SECONDS:
|
|
||||||
result.update(limits=cached["limits"], live=True, authHelpText="")
|
|
||||||
return result
|
|
||||||
|
|
||||||
request = urllib.request.Request(CREDITS_URL, headers={"Authorization": f"Bearer {token}", "Accept": "application/json"})
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(request, timeout=8) as response:
|
|
||||||
limit = credits_limit(json.loads(response.read()))
|
|
||||||
except urllib.error.HTTPError as error:
|
|
||||||
limit = None
|
|
||||||
if error.code in (401, 403):
|
|
||||||
result["usageStatusText"] = "Waiting for auth"
|
|
||||||
except Exception:
|
|
||||||
limit = None
|
|
||||||
|
|
||||||
if limit:
|
|
||||||
result.update(limits=[limit], live=True, fetchedAtMs=round(time.time() * 1000), authHelpText="")
|
|
||||||
write_json(cache, {"fetchedAtMs": result["fetchedAtMs"], "limits": result["limits"]})
|
|
||||||
else:
|
|
||||||
result["limits"] = cached.get("limits") or []
|
|
||||||
if not result["usageStatusText"] and not result["limits"]:
|
|
||||||
result["usageStatusText"] = "Grok limits unavailable"
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# The accounts `omarchy agent account` registered, read straight from its
|
|
||||||
# registry. Only a registry holding a second account matters: with one, the
|
|
||||||
# record describes ~/.grok alone.
|
|
||||||
def registered_accounts():
|
|
||||||
state = Path(os.environ.get("XDG_STATE_HOME") or (Path.home() / ".local" / "state"))
|
|
||||||
registry = read_json(state / "omarchy" / "agents" / "accounts" / "grok.json") or {}
|
|
||||||
accounts = [a for a in registry.get("accounts") or [] if isinstance(a, dict) and a.get("id")]
|
|
||||||
if len(accounts) < 2:
|
|
||||||
return []
|
|
||||||
active_id = registry.get("active") or accounts[0]["id"]
|
|
||||||
if not any(a["id"] == active_id for a in accounts):
|
|
||||||
active_id = accounts[0]["id"]
|
|
||||||
for account in accounts:
|
|
||||||
account["active"] = account["id"] == active_id
|
|
||||||
account["switch"] = {
|
|
||||||
"mode": "auto" if registry.get("switch") == "auto" else "manual",
|
|
||||||
"threshold": registry.get("threshold") or 95,
|
|
||||||
}
|
|
||||||
return accounts
|
|
||||||
|
|
||||||
|
|
||||||
def home_of(account):
|
|
||||||
return Path(account.get("home") or (Path.home() / ".grok"))
|
|
||||||
|
|
||||||
|
|
||||||
# Keyed by who the home is signed in as, so a home signed in to someone else
|
|
||||||
# never shows the last one's numbers.
|
|
||||||
def cache_key(home, fallback):
|
|
||||||
user = str(login(home).get("user_id") or "")
|
|
||||||
return re.sub(r"[^A-Za-z0-9_-]", "", user) or fallback
|
|
||||||
|
|
||||||
|
|
||||||
def account_limits(account, force):
|
|
||||||
home = home_of(account)
|
|
||||||
limits = collect_limits(home, cache_key(home, account["id"]), force)
|
|
||||||
return {
|
|
||||||
"id": account["id"],
|
|
||||||
"label": str(account.get("label") or account["id"]),
|
|
||||||
"email": str(account.get("email") or ""),
|
|
||||||
"plan": plan(home) or str(account.get("plan") or ""),
|
|
||||||
"active": account["active"],
|
|
||||||
"primary": bool(account.get("primary")),
|
|
||||||
"limits": limits["limits"],
|
|
||||||
"stale": not limits["live"],
|
|
||||||
"fetchedAt": limits["fetchedAtMs"],
|
|
||||||
"usageStatusText": limits["usageStatusText"],
|
|
||||||
"authHelpText": limits["authHelpText"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--force", action="store_true")
|
|
||||||
parser.add_argument("--limits-only", action="store_true")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
registered = registered_accounts()
|
|
||||||
accounts = [account_limits(account, args.force) for account in registered]
|
|
||||||
current = next((a for a in accounts if a["active"]), None)
|
|
||||||
if current:
|
|
||||||
tier = current["plan"]
|
|
||||||
limits = current
|
|
||||||
stale, fetched_at = current["stale"], current["fetchedAt"]
|
|
||||||
else:
|
|
||||||
home = default_home()
|
|
||||||
tier = plan(home)
|
|
||||||
limits = collect_limits(home, cache_key(home, "main"), args.force)
|
|
||||||
stale, fetched_at = not limits["live"], limits["fetchedAtMs"]
|
|
||||||
|
|
||||||
# Nobody signed in to Grok here: an empty record, which the panel skips.
|
|
||||||
# With accounts registered, the primary is ~/.grok by definition.
|
|
||||||
stats_home = Path.home() / ".grok" if accounts else default_home()
|
|
||||||
signed_in = bool(accounts) or bool(login(stats_home))
|
|
||||||
record = {
|
|
||||||
"schemaVersion": 1,
|
|
||||||
"id": AGENT_ID,
|
|
||||||
"name": AGENT_NAME,
|
|
||||||
"updatedAt": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"ready": signed_in,
|
|
||||||
"tierLabel": tier,
|
|
||||||
"usageStatusText": limits["usageStatusText"],
|
|
||||||
"authHelpText": limits["authHelpText"],
|
|
||||||
"limits": limits["limits"],
|
|
||||||
"limitsStale": stale,
|
|
||||||
"limitsFetchedAt": fetched_at,
|
|
||||||
}
|
|
||||||
record.update(cached_local_stats(stats_home, LIMITS_ONLY_SCAN_REUSE_SECONDS if args.limits_only and not args.force else 0))
|
|
||||||
if accounts:
|
|
||||||
record["accountSwitch"] = registered[0]["switch"]
|
|
||||||
record["accounts"] = accounts
|
|
||||||
print(json.dumps(record, separators=(",", ":")))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:summary=Regenerate the AI agent usage data files
|
|
||||||
# omarchy:args=[--force] [--limits-only] [--except <agent>] [agent...]
|
|
||||||
# omarchy:examples=omarchy agent usage-update | omarchy agent usage-update claude | omarchy agent usage-update --except codex
|
|
||||||
|
|
||||||
# Each omarchy-agent-usage-<agent> collector prints one display-ready JSON
|
|
||||||
# record; this writes them to ~/.local/state/omarchy/agents/usage/ where the
|
|
||||||
# agents panel watches them. Adding an agent is adding a collector — the
|
|
||||||
# panel picks up any record that appears here.
|
|
||||||
|
|
||||||
USAGE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/agents/usage"
|
|
||||||
mkdir -p "$USAGE_DIR"
|
|
||||||
|
|
||||||
flags=()
|
|
||||||
only=()
|
|
||||||
declare -A excluded
|
|
||||||
|
|
||||||
while (( $# > 0 )); do
|
|
||||||
case "$1" in
|
|
||||||
--force | --limits-only) flags+=("$1") ;;
|
|
||||||
--except)
|
|
||||||
excluded[$2]=1
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
*) only+=("$1") ;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
|
|
||||||
wanted() {
|
|
||||||
local agent="$1"
|
|
||||||
[[ -n ${excluded[$agent]} ]] && return 1
|
|
||||||
(( ${#only[@]} == 0 )) && return 0
|
|
||||||
local candidate
|
|
||||||
for candidate in "${only[@]}"; do
|
|
||||||
[[ $candidate == "$agent" ]] && return 0
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
collect() {
|
|
||||||
local collector="$1" agent="$2"
|
|
||||||
local record tmp
|
|
||||||
if ! record=$("$collector" "${flags[@]}") || [[ -z $record ]] || ! jq -e . >/dev/null 2>&1 <<<"$record"; then
|
|
||||||
echo "omarchy-agent-usage-update: $agent collector failed" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
tmp=$(mktemp "$USAGE_DIR/.$agent.XXXXXX")
|
|
||||||
printf '%s\n' "$record" >"$tmp"
|
|
||||||
mv "$tmp" "$USAGE_DIR/$agent.json"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Accounts can be signed in to someone else since they were added; the
|
|
||||||
# records name them from the registry, so bring it up to date first.
|
|
||||||
accounts_dir="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/agents/accounts"
|
|
||||||
if [[ -f $accounts_dir/claude.json || -f $accounts_dir/codex.json || -f $accounts_dir/grok.json ]]; then
|
|
||||||
"$OMARCHY_PATH/bin/omarchy-agent-account-state" refresh
|
|
||||||
fi
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# Fresh limits are when switching accounts near a limit gets decided. A switch
|
|
||||||
# changes which account the record's own limits describe, so that record is
|
|
||||||
# collected once more; the limits probes it repeats are still cached.
|
|
||||||
for agent in claude codex grok; do
|
|
||||||
if wanted "$agent" && [[ -f $accounts_dir/$agent.json ]]; then
|
|
||||||
switched=$("$OMARCHY_PATH/bin/omarchy-agent-account-state" autoswitch "$agent")
|
|
||||||
if [[ -n $switched ]]; then
|
|
||||||
flags=(--limits-only)
|
|
||||||
collect "$OMARCHY_PATH/bin/omarchy-agent-usage-$agent" "$agent" || status=1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
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,85 +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
|
|
||||||
|
|
||||||
# Install resume recovery alongside fingerprint PAM, so setup and upgrades
|
|
||||||
# cannot leave one configured without the other.
|
|
||||||
resume_hook_src="$OMARCHY_PATH/default/systemd/system-sleep/fprintd-resume"
|
|
||||||
resume_hook_dst=/usr/lib/systemd/system-sleep/fprintd-resume
|
|
||||||
stop_timeout_src="$OMARCHY_PATH/default/systemd/system/fprintd.service.d/10-stop-timeout.conf"
|
|
||||||
stop_timeout_dst=/etc/systemd/system/fprintd.service.d/10-stop-timeout.conf
|
|
||||||
|
|
||||||
# Keep the privileged probe pinned to /usr/bin; failure is not empty enrollment.
|
|
||||||
if [[ -x /usr/bin/fprintd-list ]]; then
|
|
||||||
fingerprint_status=$(/usr/bin/fprintd-list "$target_user" 2>&1 || true)
|
|
||||||
else
|
|
||||||
fingerprint_status="no"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if grep -qE '^[[:space:]]*-[[:space:]]*#[0-9]+:' <<< "$fingerprint_status"; then
|
|
||||||
echo "Configuring lock screen fingerprint authentication..."
|
|
||||||
as_root install -Dm755 -o root -g root "$resume_hook_src" "$resume_hook_dst"
|
|
||||||
as_root install -Dm644 -o root -g root "$stop_timeout_src" "$stop_timeout_dst"
|
|
||||||
as_root systemctl daemon-reload
|
|
||||||
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
|
|
||||||
elif [[ $fingerprint_status == "no" ]] || grep -qi 'has no fingers enrolled' <<< "$fingerprint_status"; then
|
|
||||||
as_root rm -f /etc/pam.d/omarchy-lock-fingerprint "$resume_hook_dst"
|
|
||||||
# Only reload when something was there: this branch also runs in the
|
|
||||||
# install chroot, where there is no systemd to reload.
|
|
||||||
if [[ -e $stop_timeout_dst ]]; then
|
|
||||||
as_root rm -f "$stop_timeout_dst"
|
|
||||||
as_root rmdir --ignore-fail-on-non-empty "$(dirname "$stop_timeout_dst")"
|
|
||||||
as_root systemctl daemon-reload
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "Could not check fingerprint enrollment; keeping the existing configuration." >&2
|
|
||||||
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,77 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:summary=Restore channel and regional pacman defaults in an installed target
|
|
||||||
# omarchy:args=<stable|rc|edge> [target-root] | --render <stable|rc|edge> <output-dir> [target-root]
|
|
||||||
# omarchy:requires-sudo=true
|
|
||||||
# omarchy:hidden=true
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
echo "Usage: omarchy-apply-pacman <stable|rc|edge> [target-root]" >&2
|
|
||||||
echo " omarchy-apply-pacman --render <stable|rc|edge> <output-dir> [target-root]" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# --render only builds the two files, so an unprivileged caller can hand root
|
|
||||||
# nothing but plain copies of the finished result.
|
|
||||||
render_dir=""
|
|
||||||
if [[ ${1:-} == "--render" ]]; then
|
|
||||||
(( $# == 3 || $# == 4 )) || usage
|
|
||||||
channel="$2"
|
|
||||||
render_dir="$3"
|
|
||||||
[[ -d $render_dir ]] || { echo "Render directory not found: $render_dir" >&2; exit 1; }
|
|
||||||
etc="${4:-/}/etc"
|
|
||||||
else
|
|
||||||
(( $# >= 1 && $# <= 2 )) || usage
|
|
||||||
channel="$1"
|
|
||||||
etc="${2:-/}/etc"
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "$channel" in
|
|
||||||
stable|rc|edge) ;;
|
|
||||||
*) echo "Invalid pacman channel: $channel" >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# The ISO writes this plain-text marker before target finalization. Missing
|
|
||||||
# markers keep existing installations global; never source it as shell code.
|
|
||||||
region=global
|
|
||||||
if [[ -e $etc/omarchy/region ]]; then
|
|
||||||
region=$(<"$etc/omarchy/region")
|
|
||||||
fi
|
|
||||||
case "$region" in
|
|
||||||
global|cn) ;;
|
|
||||||
*) echo "Unsupported region: $region (expected global or cn)" >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Build both files before replacing either. Explicit refreshes retain backups;
|
|
||||||
# ordinary updates do not call this command or enforce regional defaults.
|
|
||||||
if [[ -n $render_dir ]]; then
|
|
||||||
staging="$render_dir"
|
|
||||||
else
|
|
||||||
staging=$(mktemp -d)
|
|
||||||
trap 'rm -rf "$staging"' EXIT
|
|
||||||
fi
|
|
||||||
cp "$OMARCHY_PATH/default/pacman/pacman-$channel.conf" "$staging/pacman.conf"
|
|
||||||
if [[ $region == "global" ]]; then
|
|
||||||
cp "$OMARCHY_PATH/default/pacman/mirrorlist-$channel" "$staging/mirrorlist"
|
|
||||||
else
|
|
||||||
profile="$OMARCHY_PATH/default/regions/$region/pacman"
|
|
||||||
printf '\n' >> "$staging/pacman.conf"
|
|
||||||
cat "$profile/pacman.conf.append" >> "$staging/pacman.conf"
|
|
||||||
# pacman tries servers in order, so the regional mirror leads and the
|
|
||||||
# channel's own mirror stays as the fallback.
|
|
||||||
cat "$profile/mirrorlist-$channel.prepend" "$OMARCHY_PATH/default/pacman/mirrorlist-$channel" > "$staging/mirrorlist"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z $render_dir ]]; then
|
|
||||||
mkdir -p "$etc/pacman.d"
|
|
||||||
for file in pacman.conf pacman.d/mirrorlist; do
|
|
||||||
if [[ -e $etc/$file ]]; then
|
|
||||||
cp -f "$etc/$file" "$etc/$file.bak"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
# Copying over an existing file keeps its owner and mode, as refresh always has.
|
|
||||||
cp -f "$staging/pacman.conf" "$etc/pacman.conf"
|
|
||||||
cp -f "$staging/mirrorlist" "$etc/pacman.d/mirrorlist"
|
|
||||||
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,96 +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" '
|
|
||||||
BEGIN {
|
|
||||||
output = virt
|
|
||||||
sub(/^effect_input\./, "effect_output.", output)
|
|
||||||
}
|
|
||||||
/^Sink Input #/ {target = ""}
|
|
||||||
/^[[:space:]]*Sink:/ {target = $2}
|
|
||||||
/node\.name = / {
|
|
||||||
name = $0
|
|
||||||
sub(/.*node\.name = "/, "", name)
|
|
||||||
sub(/"$/, "", name)
|
|
||||||
if (target != "" && (name == output || (output == virt && index(name, virt) == 1))) {selected = target; exit}
|
|
||||||
if (target != "" && fallback == "" && index(name, virt) == 1) fallback = target
|
|
||||||
}
|
|
||||||
/application\.name = "EasyEffects"/ {
|
|
||||||
if (virt == "easyeffects_sink" && target != "") {selected = target; exit}
|
|
||||||
}
|
|
||||||
END {
|
|
||||||
if (selected != "") print selected
|
|
||||||
else if (fallback != "") print fallback
|
|
||||||
}')"
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# EasyEffects 8 does not play into the physical sink through a stream of its
|
|
||||||
# own: its output node links its ports straight to the sink's playback ports,
|
|
||||||
# so the sink-input search above finds nothing and the keys would move the
|
|
||||||
# EasyEffects sink -- whose volume its monitor, the input of the processing,
|
|
||||||
# does not follow. Follow the port links instead: the first ALSA or Bluetooth
|
|
||||||
# sink any EasyEffects output port is linked to.
|
|
||||||
if [[ $sink == "easyeffects_sink" ]]; then
|
|
||||||
linked="$(pw-link -l 2>/dev/null |
|
|
||||||
awk '
|
|
||||||
/^[^ ]/ {src = $0}
|
|
||||||
/^[[:space:]]*\|->/ {
|
|
||||||
if (src ~ /^ee_/ && $2 ~ /^(alsa|bluez)_output\..*:playback_/) {
|
|
||||||
sub(/:playback_.*$/, "", $2); print $2; exit
|
|
||||||
}
|
|
||||||
}')"
|
|
||||||
|
|
||||||
# Idle, EasyEffects drops those links: fall back to the output device it is
|
|
||||||
# configured to feed, when that sink exists.
|
|
||||||
if [[ -z $linked ]]; then
|
|
||||||
configured="$(sed -n 's/^outputDevice=//p' \
|
|
||||||
"${XDG_CONFIG_HOME:-$HOME/.config}/easyeffects/db/easyeffectsrc" 2>/dev/null)"
|
|
||||||
if [[ -n $configured ]] && pactl list sinks short 2>/dev/null | awk '{print $2}' | grep -qxF -- "$configured"; then
|
|
||||||
linked="$configured"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n $linked ]]; then
|
|
||||||
printf '%s\n' "$linked"
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
'
|
|
||||||