Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4505badecb |
No files matched your search
@@ -0,0 +1,2 @@
|
||||
# Merges to protected branches need sign-off from an org owner.
|
||||
* @dhh @ryanrhughes
|
||||
@@ -1,7 +1,7 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- 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.
|
||||
- name: Support
|
||||
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).
|
||||
@@ -2,5 +2,3 @@
|
||||
# Python bytecode (orchestrator)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
.claude/
|
||||
@@ -28,7 +28,7 @@ Three documentation trees, split by genre and audience:
|
||||
- Prefer `(( ))` over numeric operators inside `[[ ]]` (e.g., `(( count < 50 ))`, not `[[ $count -lt 50 ]]`)
|
||||
- Prefer a full `if`/`else` conditional for simple two-path control flow; don't rely on `exec` or `exit` in one branch to make following statements unreachable
|
||||
- For strings/paths with spaces, quote them instead of escaping spaces with `\ ` (e.g., `"$APP_DIR/Disk Usage.desktop"`, not `$APP_DIR/Disk\ Usage.desktop`)
|
||||
- Shebangs must use `#!/bin/bash` consistently (never `#!/usr/bin/env bash`). 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
|
||||
|
||||
# Command Naming
|
||||
@@ -63,30 +63,6 @@ guidance does not drift from the router.
|
||||
- `$OMARCHY_PATH` is set at the top level by the uwsm session environment and is always available to Omarchy runtime code.
|
||||
- Commands in `bin/` and Quickshell QML should rely on `$OMARCHY_PATH` / `Quickshell.env("OMARCHY_PATH")`; do not derive fallback paths from `HOME`, `Quickshell.shellDir`, or re-export/default `OMARCHY_PATH` manually.
|
||||
|
||||
# Platforms
|
||||
|
||||
Omarchy runs on x86 and aarch64 (ARM). A platform name is the CPU architecture, then a hardware family only when that family needs handling of its own:
|
||||
|
||||
- `x86` - every x86_64 machine
|
||||
- `aarch64` - every ARM machine without a family of its own (Snapdragon laptops, Raspberry Pis and ARM VMs today)
|
||||
- `aarch64-apple` - Apple Silicon Macs
|
||||
|
||||
A new family is named `aarch64-<family>` (e.g. `aarch64-qualcomm`, `aarch64-n1x`) and is added only once it needs code of its own, so `aarch64*` always means ARM. `omarchy-hw-platform` prints the most specific name. Use these exact names wherever a platform is named: package lists, pacman template directories, image manifests, dispatch registration, tests and docs. Never introduce `generic`, `arm64`, `apple-silicon` or other spellings; the old `apple-silicon`, `generic-aarch64` and `generic` are read only for compatibility with older image builders.
|
||||
|
||||
Ask a helper; don't read `uname -m` or the device tree in feature code:
|
||||
|
||||
- `omarchy-hw-x86` / `omarchy-hw-aarch64` - the CPU architecture. Use them for binary and ABI availability: a package with no aarch64 build, an x86 guest VM, multilib.
|
||||
- `omarchy-hw-aarch64-apple` (and later `omarchy-hw-aarch64-<family>`) - built on `omarchy-hw-platform`, image-build aware. Use them for hardware behaviour. `omarchy-hw-apple-silicon` is a hidden alias kept for the omarchy-mac packages; don't use it in new code.
|
||||
|
||||
Where platform code lives:
|
||||
|
||||
- Packages: `install/omarchy-base.packages` for everyone, `install/omarchy-aarch64.packages` for every ARM machine, `install/omarchy-<platform>.packages` for one family, and base packages with no aarch64 build in `install/omarchy-x86_64-only.packages`. `omarchy-pkg-defaults` composes them.
|
||||
- Pacman templates: `default/pacman/` for x86, `default/pacman/<platform>/` for each ARM platform.
|
||||
- Hardware setup: `install/hardware/<vendor-or-family>/`, gated by a predicate. `install/hardware/apple/` holds Intel Mac quirks; Apple Silicon's own setup ships in the omarchy-mac packages.
|
||||
- Boot chains Omarchy can't drive generically: a platform package behind `omarchy-lifecycle-dispatch` ([`docs/lifecycle-dispatch.md`](docs/lifecycle-dispatch.md)). Platforms that boot Limine with UKIs like x86 keep the generic path.
|
||||
|
||||
The experience should be the same on every platform. When a package or feature is missing or broken on one, the default is to get it fixed upstream (an aarch64 build, a bug report, a patch to the maintainer) and leave the gap visible meanwhile, not to paper over it. When asked for a platform workaround, say so and propose the upstream fix first. Hiding a menu entry or skipping a package is fine only for things that can't exist there (an x86 guest VM, an x86-only proprietary binary). Alternative packages and platform-only replacements are a last resort, used only when upstream can't or won't fix it, with the reason written next to the code.
|
||||
|
||||
# Privileged Commands
|
||||
|
||||
- Follow the "Privilege Escalation" section of `default/agents/skills/omarchy/SKILL.md`. It draws the
|
||||
@@ -104,7 +80,7 @@ Use these instead of raw shell commands:
|
||||
|
||||
- `omarchy-cmd-missing` / `omarchy-cmd-present` - check for commands
|
||||
- `omarchy-pkg-missing` / `omarchy-pkg-present` - check for packages (don't use these if you can just use `omarchy-pkg-add`/`omarchy-pkg-drop`)
|
||||
- `omarchy-pkg-add` - install packages from the configured pacman repos, Omarchy's included; use `omarchy-pkg-aur-add` for AUR packages
|
||||
- `omarchy-pkg-add` - install packages (handles both pacman and AUR)
|
||||
- `omarchy-pkg-drop` - remove packages; use this instead of raw `pacman -R*`
|
||||
- `omarchy-notification-send` - send desktop notifications; do not call `notify-send` directly
|
||||
- `omarchy-hw-asus-rog` - detect ASUS ROG hardware (and similar `hw-*` commands)
|
||||
@@ -113,10 +89,6 @@ Commands installed by Omarchy's default package set are runtime invariants. Invo
|
||||
|
||||
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.
|
||||
|
||||
# Background Work
|
||||
|
||||
- Long-running code (the shell, daemons, watchers) reacts to events instead of polling with a timer. Polling is the last resort, for state with no change signal; [`agents/skills/shell-dev.md`](agents/skills/shell-dev.md) lists the event sources and the push-refresh pattern.
|
||||
|
||||
# Menu
|
||||
|
||||
- The menu definition lives in `default/omarchy/omarchy-menu.jsonc`;
|
||||
@@ -124,12 +96,6 @@ Exceptions are allowed for migration and package-helper scripts where the helper
|
||||
- Do not add `aliases` to new menu entries. Aliases are reserved for
|
||||
established alternate names users already type, kept for compatibility.
|
||||
|
||||
# Language
|
||||
|
||||
- `omarchy-language en|zh` (Setup > Language) owns `LANG` in `/etc/locale.conf`; the installer sets it from its first screen. Nothing in `omarchy update` or migrations may change `/etc/locale.conf`.
|
||||
- Chinese menu labels live in `default/omarchy/omarchy-menu.zh-cn.jsonc`, merged when `LANG` starts with `zh_CN`; give a new menu row with translatable words a label there.
|
||||
- Shell text goes through `I18n.tr("English %1", arg)` from `qs.Commons`, with its Chinese in `shell/Commons/I18nZh.js` keyed by the English. Notifications and OSD messages are worded by their sender with `omarchy-text "English %1" "$arg"` (Python: `text()`, QML: `I18n.tr`), which reads the same table; the shell shows them as given, so user text is never translated. `test/shell.d/shell-i18n-test.sh` fails on a missing entry.
|
||||
|
||||
# Config Structure
|
||||
|
||||
- `config/` - default configs copied to `~/.config/`
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# 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).
|
||||
|
||||
## The Omarchy Manual
|
||||
|
||||
The manual lives in [`manual/`](manual/), which is its authoritative source.
|
||||
The manual lives in [`manual/`](manual/), which is its authoritative source. It's
|
||||
mirrored to [learn.omacom.io](https://learn.omacom.io/2/the-omarchy-manual), where
|
||||
its screenshots are also hosted.
|
||||
|
||||
- [Welcome to Omarchy!](manual/01-welcome-to-omarchy.md)
|
||||
|
||||
|
||||
@@ -70,6 +70,10 @@ 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.
|
||||
Then confirm it in the running menu per
|
||||
[`visual-verification.md`](visual-verification.md). Fontconfig prefers the
|
||||
packaged font over a copy in `~/.local/share/fonts` for the same family, so a
|
||||
preview needs either the real file replaced or a `<rejectfont>` rule in
|
||||
`~/.config/fontconfig/conf.d/` pointing fontconfig away from the packaged one.
|
||||
Restart the shell afterwards — Qt reads the font database at startup, so
|
||||
`omarchy menu refresh` alone will not pick up a changed font.
|
||||
@@ -12,10 +12,7 @@ commands and reusable setup leaves:
|
||||
- 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`.
|
||||
- in an image build every `run_logged` leaf in `install/hardware/all.sh` is queued and runs on the machine's first boot instead (`bin/omarchy-provision-hardware`, see `docs/file-layout.md`), possibly offline, using packages already in the image, and before any user exists; keep those leaves idempotent and put nothing but `run_logged` lines in `all.sh`.
|
||||
- gate platform-specific setup on a predicate built on `omarchy-hw-platform` (`omarchy-hw-aarch64-apple`), not on `uname -m`; architecture checks (`omarchy-hw-x86`, `omarchy-hw-aarch64`) are only for ABI, binary availability or a repository's `$arch`. Platform names and where platform code lives are in the Platforms section of `AGENTS.md`.
|
||||
- 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
|
||||
|
||||
@@ -163,8 +163,6 @@ 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.
|
||||
|
||||
@@ -20,14 +20,12 @@ Run `omarchy-restart-shell` after making changes to QML files.
|
||||
[`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.
|
||||
- Entry-point QML files are `Item`s (not `ShellRoot`), and accept the
|
||||
shell-injected properties `omarchyPath`, `shell`, `manifest`, and
|
||||
`pluginRegistry` / `barWidgetRegistry` as appropriate.
|
||||
- Panel / overlay / menu plugins must expose `open(payloadJson)` and
|
||||
`close()` lifecycle methods for `shell summon` and `shell hide`.
|
||||
|
||||
## Text
|
||||
|
||||
Wrap every user-visible string in `I18n.tr()` (`import qs.Commons`) and add its Chinese to `shell/Commons/I18nZh.js`; plain JS models that Node tests load stay English, and their text is wrapped where QML shows it.
|
||||
|
||||
## IPC
|
||||
|
||||
- `bin/omarchy-shell` is the canonical IPC entry point. It forwards to
|
||||
@@ -42,17 +40,6 @@ Wrap every user-visible string in `I18n.tr()` (`import qs.Commons`) and add its
|
||||
bar widgets register one target each — `omarchy.indicators`,
|
||||
`omarchy.system-update`, `omarchy.clock`. There is no `bar` target.
|
||||
|
||||
## Keeping state current without polling
|
||||
|
||||
The shell runs all day on battery, and a widget is built once per monitor, so a `Timer` that starts a `Process` costs that process times the number of monitors, every tick, forever. Don't poll for state that only changes through known events. Pick the first of these that fits:
|
||||
|
||||
- A native Quickshell service that signals on change: `UPower` and `PowerProfiles` (`Quickshell.Services.UPower`), `Hyprland` and each workspace's `lastIpcObject`, `Pipewire`, `Mpris`, `SystemTray`, `Notifications`.
|
||||
- An event stream the shell already runs, such as the Hyprland IPC events or the long-running `pactl subscribe` in `Commons/UntypedInput.qml`. Extend it rather than starting a second one.
|
||||
- A file watch: `FileView` with `watchChanges`, or the directory watch in `plugins/bar/Bar.qml`, when the state lives in a file the user can read.
|
||||
- A push from the command that changes the state. If only Omarchy commands change it, have them call `omarchy-shell -q <target> refresh` after the change, the way `omarchy-reminder` and `omarchy-sudo-passwordless` do. When the change happens later, without a command running (an expiry, say), schedule the refresh with a transient `systemd-run --user --on-active=…` timer. A root-owned timer cannot reach the user's shell.
|
||||
|
||||
Polling is the last resort, kept for state with no change signal at all (a remote API, say). Then gate the timer on the panel being open where you can, pick the longest interval the UI can tolerate, and put the poll in one shared service rather than in a per-monitor widget. Never poll a privileged probe: every `sudo -n` can write to the auth log.
|
||||
|
||||
## Editing widget files with glyphs
|
||||
|
||||
Widget files in `shell/plugins/bar/widgets/` contain Nerd Font glyphs as raw
|
||||
@@ -60,7 +47,3 @@ 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)`.
|
||||
|
||||
## Palette references
|
||||
|
||||
Import `qs.Commons as Commons` and use `Commons.Color` for the palette singleton, including `Connections.target`. Qt 6.12 introduces a `Color` type in `QtQuick` that shadows unqualified palette references. This applies to first-party components and third-party plugin QML alike. Keep the unqualified `qs.Commons` import when the file also uses `Style`, `Util`, or `Border`.
|
||||
@@ -15,7 +15,10 @@ Take a full-screen screenshot without opening the editor:
|
||||
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.
|
||||
The command prints the saved path and writes to the configured Pictures
|
||||
directory. Use `omarchy screenshot` for the interactive smart-region flow.
|
||||
Capture reference and candidate states as separate images when changing a
|
||||
layer-shell surface or layout, then compare both.
|
||||
|
||||
Record a short full-screen video for animation, transition, timing, capture, or
|
||||
screen-recording changes:
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
Version=1.0
|
||||
Name=Basecamp
|
||||
Exec=omarchy-launch-webapp https://launchpad.37signals.com
|
||||
StartupWMClass=chrome-launchpad.37signals.com__-Default
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=basecamp
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
Version=1.0
|
||||
Name=Discord
|
||||
Exec=omarchy-launch-webapp https://discord.com/channels/@me
|
||||
StartupWMClass=chrome-discord.com__channels_@me-Default
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=omarchy-discord
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
[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
|
||||
File renamed without changes.
@@ -2,7 +2,6 @@
|
||||
Version=1.0
|
||||
Name=Google Contacts
|
||||
Exec=omarchy-launch-webapp https://contacts.google.com/
|
||||
StartupWMClass=chrome-contacts.google.com__-Default
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=google-contacts
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
Version=1.0
|
||||
Name=Google Maps
|
||||
Exec=omarchy-launch-webapp https://maps.google.com
|
||||
StartupWMClass=chrome-maps.google.com__-Default
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=google-maps
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
Version=1.0
|
||||
Name=Google Messages
|
||||
Exec=omarchy-launch-webapp https://messages.google.com/web/conversations
|
||||
StartupWMClass=chrome-messages.google.com__web_conversations-Default
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=google-messages
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
Version=1.0
|
||||
Name=Google Photos
|
||||
Exec=omarchy-launch-webapp https://photos.google.com/
|
||||
StartupWMClass=chrome-photos.google.com__-Default
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=google-photos
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
Version=1.0
|
||||
Name=WhatsApp
|
||||
Exec=omarchy-launch-webapp https://web.whatsapp.com/
|
||||
StartupWMClass=chrome-web.whatsapp.com__-Default
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=whatsapp
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
Version=1.0
|
||||
Name=X
|
||||
Exec=omarchy-launch-webapp https://x.com/
|
||||
StartupWMClass=chrome-x.com__-Default
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=x
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
Version=1.0
|
||||
Name=YouTube
|
||||
Exec=omarchy-launch-webapp https://youtube.com/
|
||||
StartupWMClass=chrome-youtube.com__-Default
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=youtube
|
||||
|
||||
+1
-29
@@ -26,7 +26,7 @@ declare -A ROUTE_IS_ALIAS
|
||||
declare -A BINARY_TO_KEY
|
||||
declare -A GROUP_DESCRIPTIONS
|
||||
|
||||
GROUP_DESCRIPTIONS[agent]="AI coding agents, their usage, and subscription accounts"
|
||||
GROUP_DESCRIPTIONS[agent]="AI coding agent usage data"
|
||||
GROUP_DESCRIPTIONS[ascii]="Text drawn as ASCII art"
|
||||
GROUP_DESCRIPTIONS[audio]="Audio input and output controls"
|
||||
GROUP_DESCRIPTIONS[bar]="Omarchy shell bar layout and settings"
|
||||
@@ -46,7 +46,6 @@ GROUP_DESCRIPTIONS[finalize]="Finalize user setup"
|
||||
GROUP_DESCRIPTIONS[default]="Default application selection"
|
||||
GROUP_DESCRIPTIONS[dev]="Omarchy development tools"
|
||||
GROUP_DESCRIPTIONS[disk]="Disk performance helpers"
|
||||
GROUP_DESCRIPTIONS[dictation]="Dictation recording"
|
||||
GROUP_DESCRIPTIONS[display]="Display and text scaling"
|
||||
GROUP_DESCRIPTIONS[dns]="DNS resolver configuration"
|
||||
GROUP_DESCRIPTIONS[drive]="Drive selection and encryption"
|
||||
@@ -59,7 +58,6 @@ GROUP_DESCRIPTIONS[hw]="Hardware detection and controls"
|
||||
GROUP_DESCRIPTIONS[hyprland]="Hyprland window, monitor, and toggle controls"
|
||||
GROUP_DESCRIPTIONS[install]="Optional software installers"
|
||||
GROUP_DESCRIPTIONS[installed]="Installed optional service checks"
|
||||
GROUP_DESCRIPTIONS[language]="System language"
|
||||
GROUP_DESCRIPTIONS[launch]="Application launchers"
|
||||
GROUP_DESCRIPTIONS[menu]="Omarchy menu commands"
|
||||
GROUP_DESCRIPTIONS[migrate]="Migration runner"
|
||||
@@ -67,7 +65,6 @@ GROUP_DESCRIPTIONS[monitor]="Monitor status helpers"
|
||||
GROUP_DESCRIPTIONS[network]="Network status helpers"
|
||||
GROUP_DESCRIPTIONS[notification]="Notification helpers"
|
||||
GROUP_DESCRIPTIONS[mise]="Mise tool wrappers"
|
||||
GROUP_DESCRIPTIONS[openclaw]="OpenClaw agent platform setup"
|
||||
GROUP_DESCRIPTIONS[osd]="On-screen display status helpers"
|
||||
GROUP_DESCRIPTIONS[pkg]="Package management helpers"
|
||||
GROUP_DESCRIPTIONS[plugin]="Omarchy shell plugin and bar widget management"
|
||||
@@ -373,20 +370,6 @@ command_requires_args() {
|
||||
[[ -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() {
|
||||
local group="$1"
|
||||
local file=""
|
||||
@@ -972,17 +955,6 @@ dispatch_fast_or_help() {
|
||||
remaining=("${args[@]:DIRECT_RESOLVED_COUNT}")
|
||||
binary_path="$OMARCHY_BIN_DIR/$DIRECT_RESOLVED_BINARY"
|
||||
|
||||
if (( ${#remaining[@]} == 1 || ${#remaining[@]} == 2 )) && [[ ${remaining[0]} != -* ]] &&
|
||||
{ (( ${#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
|
||||
echo "Binary is missing or not executable: $DIRECT_RESOLVED_BINARY" >&2
|
||||
|
||||
+14
-52
@@ -52,7 +52,8 @@ if omarchy-cmd-missing "$agent"; then
|
||||
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.
|
||||
# with its own spelling of "don't stop to ask". Pi, Ori and Dim have none to
|
||||
# skip.
|
||||
case "$agent" in
|
||||
opencode)
|
||||
command=(opencode --auto)
|
||||
@@ -78,43 +79,27 @@ claude)
|
||||
command=(claude --permission-mode auto)
|
||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
||||
;;
|
||||
dim)
|
||||
# `dim exec` is the one-shot prompt form; interactive dim is the bare TUI.
|
||||
if [[ -n ${prompt:-} ]]; then
|
||||
command=(dim exec "$prompt")
|
||||
else
|
||||
command=(dim)
|
||||
fi
|
||||
;;
|
||||
grok)
|
||||
command=(grok --permission-mode bypassPermissions)
|
||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
||||
;;
|
||||
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")
|
||||
kimi)
|
||||
# --prompt is kimi's one-shot form, like `crush run`; --auto spares its questions.
|
||||
command=(kimi --auto)
|
||||
[[ -n ${prompt:-} ]] && command+=(--prompt "$prompt")
|
||||
;;
|
||||
codex)
|
||||
command=(codex --approve-for-me)
|
||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
||||
;;
|
||||
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")
|
||||
@@ -136,29 +121,6 @@ pi)
|
||||
;;
|
||||
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
|
||||
|
||||
@@ -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 "$(omarchy-text "Couldn't add the %1 account" "$name")" "$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 "$(omarchy-text "Installing the %1 CLI…" "$name")"
|
||||
if [[ $events == "true" ]]; then
|
||||
mise use -g "$package" >/dev/null 2>&1 || fail "$(omarchy-text "Installing the %1 CLI didn't finish." "$name")"
|
||||
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 "$(omarchy-text "No %1 account named %2." "$name" "$reauth")"
|
||||
|
||||
if [[ $account_home == "$HOME/.$provider" ]]; then
|
||||
say "$(omarchy-text "Sign in to %1 again in the browser window that opens." "$name")"
|
||||
login || fail "$(omarchy-text "The %1 sign-in didn't finish." "$name")"
|
||||
else
|
||||
say "$(omarchy-text "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 "$(omarchy-text "The %1 sign-in didn't finish." "$name")"
|
||||
fi
|
||||
|
||||
omarchy-agent-usage-update --force "$provider" >/dev/null 2>&1 &
|
||||
finish "$(omarchy-text "Signed in to %1 again." "$name")"
|
||||
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 "$(omarchy-text "Sign in to %1 in the browser window that opens." "$name")"
|
||||
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 "$(omarchy-text "Signed in to %1." "$name")"
|
||||
else
|
||||
fail "$(omarchy-text "The %1 sign-in didn't finish." "$name")"
|
||||
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 "$(omarchy-text "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 "$(omarchy-text "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 "$(omarchy-text "Added %1." "$added")"
|
||||
else
|
||||
finish "$(omarchy-text "Added %1." "$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,794 +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
|
||||
|
||||
|
||||
def text(english: str, *args: object) -> str:
|
||||
"""English with %1.. filled, in the session's language, from the shell's table."""
|
||||
return subprocess.run(["omarchy-text", english, *map(str, args)], capture_output=True, text=True, check=True).stdout.rstrip("\n")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- 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(text("%1 is not a pending %2 login", pending, PROVIDERS[provider]['name']))
|
||||
|
||||
found = identity(provider, pending)
|
||||
if not found["accountId"]:
|
||||
shutil.rmtree(pending, ignore_errors=True)
|
||||
raise AccountError(text("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(text(
|
||||
"That's %1 (%2). Try again, and sign in as the other account in the private window.",
|
||||
account["label"], found["email"] or text("already added"),
|
||||
))
|
||||
|
||||
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(text("Give the account a name"))
|
||||
with Locked(provider) as registry:
|
||||
account = find(registry, target)
|
||||
if not account:
|
||||
raise AccountError(text("No %1 account named %2", PROVIDERS[provider]['name'], 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(text("No %1 account named %2", PROVIDERS[provider]['name'], 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(text("No %1 account named %2", PROVIDERS[provider]['name'], target))
|
||||
if account.get("primary"):
|
||||
raise AccountError(text("%1 is the login in %2; it can't be removed here.", account["label"], primary_home(provider)))
|
||||
home = account_home(provider, account)
|
||||
if home_in_use(provider, home):
|
||||
raise AccountError(text("A running %1 session uses %2. Quit it first, then remove the account.", PROVIDERS[provider]['name'], account["label"]))
|
||||
# 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(text("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(text("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 text("%1d %2h", days, hours)
|
||||
if hours:
|
||||
return text("%1h %2m", hours, minutes)
|
||||
return text("%1m", minutes)
|
||||
|
||||
|
||||
# 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"]
|
||||
staying = text("Staying on %1.", source["label"])
|
||||
if soonest:
|
||||
staying = text("Staying on %1. %2 resets in %3.", source["label"], soonest[1]["label"], duration(soonest[0] - now))
|
||||
notify(text("All %1 accounts are over %2%", name, threshold), staying)
|
||||
return False
|
||||
|
||||
target = decision["to"]
|
||||
headline = text("%1 is at %2% of a %3 limit", source["label"], round(decision["fromPeak"]), name)
|
||||
if decision["action"] == "switch":
|
||||
registry["active"] = target["id"]
|
||||
if not target.get("primary"):
|
||||
link_shared(provider, account_home(provider, target))
|
||||
notify(headline, text("New %1 sessions now use %2 (%3%). Running sessions stay on %4.", name, target["label"], round(decision["toPeak"]), source["label"]))
|
||||
return True
|
||||
notify(
|
||||
headline,
|
||||
text("%1 is at %2%. Click to switch new sessions to it.", target["label"], round(decision["toPeak"])),
|
||||
["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=$(omarchy-text "Running sessions stay on %1." "$previous")
|
||||
echo "New $name sessions now use $active. $message"
|
||||
omarchy-notification-send -g "$(omarchy-text "New %1 sessions now use %2" "$name" "$active")" "$message"
|
||||
# The usage record's own limits describe the active account.
|
||||
omarchy-agent-usage-update --limits-only "$1" >/dev/null 2>&1 &
|
||||
fi
|
||||
+110
-498
@@ -22,7 +22,6 @@ import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
@@ -33,18 +32,9 @@ from typing import Any
|
||||
|
||||
AGENT_ID = "claude"
|
||||
AGENT_NAME = "Claude Code"
|
||||
def text(english, *args):
|
||||
"""English with %1.. filled, in the session's language, from the shell's table."""
|
||||
return subprocess.run(["omarchy-text", english, *map(str, args)], capture_output=True, text=True, check=True).stdout.rstrip("\n")
|
||||
|
||||
|
||||
AUTH_HELP = "Run `claude auth login` to restore authoritative usage."
|
||||
USAGE_ENDPOINT = "https://api.anthropic.com/api/oauth/usage"
|
||||
PROBE_MIN_INTERVAL_SECONDS = 15
|
||||
# Anthropic rate-limits its usage endpoint readily. A refused re-check of
|
||||
# numbers this recent still describes the account; only older ones are
|
||||
# reported as stale.
|
||||
CURRENT_ENOUGH_SECONDS = 900
|
||||
|
||||
|
||||
def config_dir() -> Path:
|
||||
@@ -128,209 +118,12 @@ def empty_bucket() -> dict[str, int]:
|
||||
# ---------------------------------------------------------------- local scan
|
||||
|
||||
|
||||
# Transcripts are append-only, and most of the corpus never changes between
|
||||
# ticks, so the scan keeps a per-file index: the unique usage records already
|
||||
# parsed out of each file plus the byte offset they end at. A file whose size
|
||||
# and mtime match the index is not opened; a file that grew is read from the
|
||||
# stored offset; anything else (truncated, rewritten, replaced, unknown) is
|
||||
# read from the start. Without this the whole history was re-parsed every
|
||||
# refresh, and the cost grew for the life of the machine.
|
||||
|
||||
INDEX_VERSION = 4
|
||||
|
||||
# How much of each end of what was read the index remembers a hash of, so a
|
||||
# file replaced or rewritten with more than it had is not mistaken for an
|
||||
# append.
|
||||
INDEX_HEAD_BYTES = 4096
|
||||
|
||||
|
||||
# Records hold local days, so an index is only true in the timezone that
|
||||
# wrote it; after a change every transcript is read again.
|
||||
def index_zone() -> list[Any]:
|
||||
return [os.environ.get("TZ"), list(time.tzname), time.timezone, time.altzone]
|
||||
|
||||
|
||||
def load_index(index_file: Path) -> dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(index_file.read_text(encoding="utf-8"))
|
||||
if (
|
||||
isinstance(data, dict)
|
||||
and data.get("version") == INDEX_VERSION
|
||||
and data.get("zone") == index_zone()
|
||||
and isinstance(data.get("files"), dict)
|
||||
):
|
||||
return data["files"]
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
# What was already read, by its first and last few kilobytes: a transcript
|
||||
# rewritten in place rather than appended to changes one or the other.
|
||||
def read_digest(path: Path, length: int) -> str:
|
||||
digest = hashlib.sha1()
|
||||
with path.open("rb") as handle:
|
||||
digest.update(handle.read(min(length, INDEX_HEAD_BYTES)))
|
||||
handle.seek(max(0, length - INDEX_HEAD_BYTES))
|
||||
digest.update(handle.read(min(length, INDEX_HEAD_BYTES)))
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def parse_usage_records(path: Path, entry: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Return the index entry for one transcript, reusing `entry` where the file
|
||||
only grew. Records are [key, model, day, input, output, cacheRead,
|
||||
cacheWrite, session]; `day` is None when the line carries no timestamp."""
|
||||
stat = path.stat()
|
||||
size = stat.st_size
|
||||
mtime = stat.st_mtime_ns
|
||||
|
||||
if entry and entry.get("size") == size and entry.get("mtime") == mtime:
|
||||
return entry
|
||||
|
||||
# Only the same file, grown past what was read with what was read still in
|
||||
# place, is an append. A transcript replaced by a larger one is a new file,
|
||||
# and one rewritten in place has a different start or end.
|
||||
if (
|
||||
entry
|
||||
and size > number(entry.get("size"))
|
||||
and entry.get("file") == [stat.st_dev, stat.st_ino]
|
||||
and entry.get("head") == read_digest(path, number(entry.get("offset")))
|
||||
):
|
||||
records = list(entry.get("records") or [])
|
||||
offset = number(entry.get("offset"))
|
||||
line_number = number(entry.get("lines"))
|
||||
else:
|
||||
records = []
|
||||
offset = 0
|
||||
line_number = 0
|
||||
|
||||
# A streamed response is several lines sharing one message id, and the
|
||||
# first line's output_tokens is a placeholder while the last carries the
|
||||
# real count. Keep each message's line with the highest output count (the
|
||||
# last one read wins a tie), whole: a response that fell back to another
|
||||
# model mid-stream has lines that are separate snapshots, and mixing fields
|
||||
# across them over-counts cache tokens and credits the wrong model.
|
||||
positions = {record[0]: index for index, record in enumerate(records)}
|
||||
with path.open("rb") as handle:
|
||||
handle.seek(offset)
|
||||
for raw in handle:
|
||||
if not raw.endswith(b"\n"):
|
||||
# A line still being written; pick it up whole next time.
|
||||
break
|
||||
offset += len(raw)
|
||||
line_number += 1
|
||||
|
||||
# Cheap pre-filter before JSON parsing keeps files with unrelated
|
||||
# lines inexpensive.
|
||||
if b'"usage":' not in raw:
|
||||
continue
|
||||
|
||||
try:
|
||||
parsed = json.loads(raw.decode("utf-8", errors="replace"))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
message = parsed.get("message") if isinstance(parsed.get("message"), dict) else {}
|
||||
if parsed.get("type") != "assistant" and message.get("role") != "assistant":
|
||||
continue
|
||||
|
||||
usage = message.get("usage") or parsed.get("usage")
|
||||
if not isinstance(usage, dict):
|
||||
continue
|
||||
|
||||
message_id = message.get("id") or parsed.get("messageId") or ""
|
||||
key = str(message_id) if message_id else f"{path}:{parsed.get('uuid') or parsed.get('requestId') or line_number}"
|
||||
|
||||
input_tokens = usage_token(usage, "input_tokens", "inputTokens")
|
||||
output_tokens = usage_token(usage, "output_tokens", "outputTokens")
|
||||
cache_read = usage_token(usage, "cache_read_input_tokens", "cacheReadInputTokens")
|
||||
cache_write = usage_token(usage, "cache_creation_input_tokens", "cacheCreationInputTokens")
|
||||
if input_tokens + output_tokens + cache_read + cache_write <= 0:
|
||||
continue
|
||||
|
||||
held = positions.get(key)
|
||||
if held is not None and output_tokens < records[held][4]:
|
||||
continue
|
||||
|
||||
timestamp = parsed.get("timestamp") or message.get("timestamp")
|
||||
record = [
|
||||
key,
|
||||
str(message.get("model") or parsed.get("model") or "claude"),
|
||||
local_date_from_timestamp(timestamp) if timestamp is not None else None,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cache_read,
|
||||
cache_write,
|
||||
str(parsed.get("sessionId") or path),
|
||||
]
|
||||
if held is None:
|
||||
positions[key] = len(records)
|
||||
records.append(record)
|
||||
else:
|
||||
records[held] = record
|
||||
|
||||
return {
|
||||
"size": size,
|
||||
"file": [stat.st_dev, stat.st_ino],
|
||||
"mtime": mtime,
|
||||
"head": read_digest(path, offset),
|
||||
"offset": offset,
|
||||
"lines": line_number,
|
||||
"records": records,
|
||||
}
|
||||
|
||||
|
||||
def scan_projects(projects_path: Path, index_file: Path | None = None) -> dict[str, Any]:
|
||||
previous = load_index(index_file) if index_file else {}
|
||||
current: dict[str, Any] = {}
|
||||
|
||||
# Directory order, as before: when one API message shows up in several
|
||||
# transcripts with the same output count, the last file visited wins, and
|
||||
# the numbers must not move because the scan got faster.
|
||||
order: list[str] = []
|
||||
unreadable_count = 0
|
||||
unreadable_examples: list[str] = []
|
||||
files = projects_path.rglob("*.jsonl") if projects_path.is_dir() else []
|
||||
for path in files:
|
||||
key = str(path)
|
||||
try:
|
||||
current[key] = parse_usage_records(path, previous.get(key))
|
||||
except Exception as exc:
|
||||
unreadable_count += 1
|
||||
if len(unreadable_examples) < 3:
|
||||
unreadable_examples.append(f"{path}: {exc}")
|
||||
# What was read before is still true of the file.
|
||||
if key in previous:
|
||||
current[key] = previous[key]
|
||||
if key in current:
|
||||
order.append(key)
|
||||
|
||||
if unreadable_count:
|
||||
examples = "; ".join(unreadable_examples)
|
||||
print(
|
||||
f"Ignoring {unreadable_count} unreadable Claude project files"
|
||||
f" (examples: {examples})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
if index_file and current != previous:
|
||||
try:
|
||||
write_json(index_file, {"version": INDEX_VERSION, "zone": index_zone(), "files": current})
|
||||
except Exception as exc:
|
||||
print(f"Could not write Claude scan index {index_file}: {exc}", file=sys.stderr)
|
||||
|
||||
return summarize_records(current, order)
|
||||
|
||||
|
||||
def summarize_records(indexed: dict[str, Any], order: list[str]) -> dict[str, Any]:
|
||||
def scan_projects(projects_path: Path) -> dict[str, Any]:
|
||||
today = local_date_string()
|
||||
recent_dates = recent_date_strings()
|
||||
recent = {day: {"date": day, "messageCount": 0} for day in recent_dates}
|
||||
|
||||
# One API message can show up in several transcripts (a resumed session
|
||||
# carries the earlier ones). As within a file, the record with the highest
|
||||
# output count wins, the last one visited breaking a tie.
|
||||
seen: dict[str, list[Any]] = {}
|
||||
seen: set[str] = set()
|
||||
sessions: set[str] = set()
|
||||
active_days: set[str] = set()
|
||||
today_sessions: set[str] = set()
|
||||
@@ -340,38 +133,68 @@ def summarize_records(indexed: dict[str, Any], order: list[str]) -> dict[str, An
|
||||
today_prompt_count = 0
|
||||
today_token_total = 0
|
||||
|
||||
for key in order:
|
||||
for record in indexed[key].get("records") or []:
|
||||
held = seen.get(record[0])
|
||||
if held is not None and record[4] < held[4]:
|
||||
continue
|
||||
seen[record[0]] = record
|
||||
files = projects_path.rglob("*.jsonl") if projects_path.is_dir() else []
|
||||
for path in files:
|
||||
try:
|
||||
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
||||
for line_number, line in enumerate(handle, 1):
|
||||
# Cheap pre-filter before JSON parsing keeps files with unrelated
|
||||
# lines inexpensive.
|
||||
if '"usage":' not in line:
|
||||
continue
|
||||
|
||||
for _, model, day, input_tokens, output_tokens, cache_read, cache_write, session_key in seen.values():
|
||||
if day is None:
|
||||
day = today
|
||||
total = input_tokens + output_tokens + cache_read + cache_write
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
sessions.add(session_key)
|
||||
active_days.add(day)
|
||||
prompts += 1
|
||||
message = entry.get("message") if isinstance(entry.get("message"), dict) else {}
|
||||
if entry.get("type") != "assistant" and message.get("role") != "assistant":
|
||||
continue
|
||||
|
||||
bucket = usage_by_model.setdefault(model, empty_bucket())
|
||||
bucket["inputTokens"] += input_tokens
|
||||
bucket["outputTokens"] += output_tokens
|
||||
bucket["cacheReadInputTokens"] += cache_read
|
||||
bucket["cacheCreationInputTokens"] += cache_write
|
||||
usage = message.get("usage") or entry.get("usage")
|
||||
if not isinstance(usage, dict):
|
||||
continue
|
||||
|
||||
if day in recent:
|
||||
# recentDays.messageCount is actually a token total, despite the
|
||||
# legacy name shared with synced snapshots.
|
||||
recent[day]["messageCount"] += total
|
||||
message_id = message.get("id") or entry.get("messageId") or ""
|
||||
unique_key = str(message_id) if message_id else f"{path}:{entry.get('uuid') or entry.get('requestId') or line_number}"
|
||||
if unique_key in seen:
|
||||
continue
|
||||
seen.add(unique_key)
|
||||
|
||||
if day == today:
|
||||
today_prompt_count += 1
|
||||
today_sessions.add(session_key)
|
||||
today_token_total += total
|
||||
today_tokens[model] = today_tokens.get(model, 0) + total
|
||||
input_tokens = usage_token(usage, "input_tokens", "inputTokens")
|
||||
output_tokens = usage_token(usage, "output_tokens", "outputTokens")
|
||||
cache_read = usage_token(usage, "cache_read_input_tokens", "cacheReadInputTokens")
|
||||
cache_write = usage_token(usage, "cache_creation_input_tokens", "cacheCreationInputTokens")
|
||||
total = input_tokens + output_tokens + cache_read + cache_write
|
||||
if total <= 0:
|
||||
continue
|
||||
|
||||
model = str(message.get("model") or entry.get("model") or "claude")
|
||||
day = local_date_from_timestamp(entry.get("timestamp") or message.get("timestamp"))
|
||||
session_key = str(entry.get("sessionId") or path)
|
||||
sessions.add(session_key)
|
||||
active_days.add(day)
|
||||
prompts += 1
|
||||
|
||||
bucket = usage_by_model.setdefault(model, empty_bucket())
|
||||
bucket["inputTokens"] += input_tokens
|
||||
bucket["outputTokens"] += output_tokens
|
||||
bucket["cacheReadInputTokens"] += cache_read
|
||||
bucket["cacheCreationInputTokens"] += cache_write
|
||||
|
||||
if day in recent:
|
||||
# recentDays.messageCount is actually a token total, despite the
|
||||
# legacy name shared with synced snapshots.
|
||||
recent[day]["messageCount"] += total
|
||||
|
||||
if day == today:
|
||||
today_prompt_count += 1
|
||||
today_sessions.add(session_key)
|
||||
today_token_total += total
|
||||
today_tokens[model] = today_tokens.get(model, 0) + total
|
||||
except Exception as exc:
|
||||
print(f"Ignoring unreadable Claude project file {path}: {exc}", file=sys.stderr)
|
||||
|
||||
return {
|
||||
"todayPrompts": today_prompt_count,
|
||||
@@ -390,28 +213,18 @@ def summarize_records(indexed: dict[str, Any], order: list[str]) -> dict[str, An
|
||||
}
|
||||
|
||||
|
||||
def scan_cache_paths(projects_path: Path) -> tuple[Path, Path, Path]:
|
||||
def scan_cache_paths(projects_path: Path) -> tuple[Path, Path]:
|
||||
digest = hashlib.sha1(str(projects_path).encode("utf-8")).hexdigest()[:16]
|
||||
root = cache_root()
|
||||
return root / f"claude-scan-{digest}.json", root / f"claude-scan-{digest}.lock", root / f"claude-index-{digest}.json"
|
||||
|
||||
|
||||
def read_json(path: Path) -> dict[str, Any] | None:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
return root / f"claude-scan-{digest}.json", root / f"claude-scan-{digest}.lock"
|
||||
|
||||
|
||||
def read_fresh_json(path: Path, max_age_seconds: float) -> dict[str, Any] | None:
|
||||
if max_age_seconds <= 0 or not path.exists():
|
||||
return None
|
||||
try:
|
||||
# A negative age means the mtime is in the future: the clock moved
|
||||
# backwards since the write, so the cache's freshness cannot be trusted.
|
||||
age = time.time() - path.stat().st_mtime
|
||||
if 0 <= age <= max_age_seconds:
|
||||
return read_json(path)
|
||||
if time.time() - path.stat().st_mtime <= max_age_seconds:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
@@ -435,8 +248,8 @@ def write_json(path: Path, payload: dict[str, Any]) -> None:
|
||||
raise
|
||||
|
||||
|
||||
def cached_scan(projects_path: Path, max_age_seconds: float, use_index: bool = True) -> dict[str, Any]:
|
||||
cache_file, lock_file, index_file = scan_cache_paths(projects_path)
|
||||
def cached_scan(projects_path: Path, max_age_seconds: float) -> dict[str, Any]:
|
||||
cache_file, lock_file = scan_cache_paths(projects_path)
|
||||
|
||||
cached = read_fresh_json(cache_file, max_age_seconds)
|
||||
if cached is not None:
|
||||
@@ -447,9 +260,7 @@ def cached_scan(projects_path: Path, max_age_seconds: float, use_index: bool = T
|
||||
cached = read_fresh_json(cache_file, max_age_seconds)
|
||||
if cached is not None:
|
||||
return cached
|
||||
if not use_index:
|
||||
index_file.unlink(missing_ok=True)
|
||||
summary = scan_projects(projects_path, index_file)
|
||||
summary = scan_projects(projects_path)
|
||||
write_json(cache_file, summary)
|
||||
return summary
|
||||
|
||||
@@ -526,27 +337,11 @@ def today_prompts_from_history(claude_dir: Path) -> tuple[int, int]:
|
||||
# provider, model, and token usage on every assistant message.
|
||||
|
||||
|
||||
# `omp --profile=<name>` (and pi's equivalent) relocates the whole agent tree
|
||||
# under <base>/profiles/<name>/, so a subscription driven entirely through a
|
||||
# profile leaves the default root empty and its usage uncounted. Sessions are
|
||||
# keyed by file path, so a profile adds sessions rather than double-counting
|
||||
# the default root.
|
||||
def pi_session_roots() -> list[Path]:
|
||||
roots: list[Path] = []
|
||||
for base in (Path.home() / ".pi", Path.home() / ".omp"):
|
||||
roots.append(base / "agent" / "sessions")
|
||||
profiles = base / "profiles"
|
||||
try:
|
||||
# Sorted so the scan order does not depend on directory order.
|
||||
roots.extend(sorted(child / "agent" / "sessions" for child in profiles.iterdir() if child.is_dir()))
|
||||
except OSError:
|
||||
# No profiles directory, or it is unreadable: the default root stands.
|
||||
pass
|
||||
return roots
|
||||
|
||||
|
||||
def scan_pi_usage(max_age_seconds: float) -> dict[str, Any] | None:
|
||||
roots = pi_session_roots()
|
||||
roots = [
|
||||
Path.home() / ".pi" / "agent" / "sessions",
|
||||
Path.home() / ".omp" / "agent" / "sessions",
|
||||
]
|
||||
cache_file = cache_root() / "claude-pi-sessions.json"
|
||||
cached = read_fresh_json(cache_file, max_age_seconds)
|
||||
if cached is not None:
|
||||
@@ -678,36 +473,16 @@ def scan_opencode_usage(max_age_seconds: float) -> dict[str, Any] | None:
|
||||
return None
|
||||
try:
|
||||
conn.execute("PRAGMA query_only = ON")
|
||||
tables = {name for (name,) in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")}
|
||||
# opencode v1 keeps messages in `message`, with the role, provider and model
|
||||
# in each row's data. v2 writes `session_message` instead: the role moves to
|
||||
# the `type` column and the provider and model nest under data.model. A
|
||||
# database can hold either or both.
|
||||
queries = []
|
||||
if "message" in tables:
|
||||
queries.append(("opencode:", "SELECT session_id, data, NULL FROM message"))
|
||||
if "session_message" in tables:
|
||||
queries.append(("opencode-v2:", "SELECT session_id, data, type FROM session_message"))
|
||||
rows = ((prefix, row) for prefix, query in queries for row in conn.execute(query))
|
||||
for session_prefix, (session_id, raw, row_type) in rows:
|
||||
for session_id, raw in conn.execute("SELECT session_id, data FROM message"):
|
||||
# One malformed row must not abort the scan, so every shape assumption
|
||||
# lives inside the try.
|
||||
try:
|
||||
entry = json.loads(raw)
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if row_type is None:
|
||||
role = entry.get("role")
|
||||
provider = entry.get("providerID")
|
||||
model_id = entry.get("modelID")
|
||||
else:
|
||||
role = row_type
|
||||
model_info = entry.get("model") or {}
|
||||
provider = model_info.get("providerID")
|
||||
model_id = model_info.get("id")
|
||||
# Exact match: opencode provider ids are free-form, and a custom
|
||||
# "anthropic-proxy" gateway is not this subscription.
|
||||
if role != "assistant" or str(provider or "") != "anthropic":
|
||||
if not isinstance(entry, dict) or entry.get("role") != "assistant":
|
||||
continue
|
||||
if str(entry.get("providerID") or "") != "anthropic":
|
||||
continue
|
||||
tokens = entry.get("tokens") or {}
|
||||
cache = tokens.get("cache") or {}
|
||||
@@ -722,10 +497,10 @@ def scan_opencode_usage(max_age_seconds: float) -> dict[str, Any] | None:
|
||||
|
||||
created = number((entry.get("time") or {}).get("created"))
|
||||
day = dt.datetime.fromtimestamp(created / 1000).strftime("%Y-%m-%d") if created > 0 else today
|
||||
model = str(model_id or "claude").rstrip("/").split("/")[-1]
|
||||
model = str(entry.get("modelID") or "claude").rstrip("/").split("/")[-1]
|
||||
except Exception:
|
||||
continue
|
||||
session_key = session_prefix + str(session_id)
|
||||
session_key = "opencode:" + str(session_id)
|
||||
sessions.add(session_key)
|
||||
active_days.add(day)
|
||||
prompts += 1
|
||||
@@ -806,73 +581,26 @@ def merge_stats(base: dict[str, Any], extra: dict[str, Any]) -> dict[str, Any]:
|
||||
# CLI's login. Nothing else leaves the credential store: the token goes
|
||||
# nowhere but the Authorization header of the limits probe, and only the
|
||||
# plan label may travel into the printed record.
|
||||
def oauth_login(claude_dir: Path) -> tuple[str, int, int, str]:
|
||||
def oauth_login(claude_dir: Path) -> tuple[str, int, str]:
|
||||
try:
|
||||
data = json.loads((claude_dir / ".credentials.json").read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return "", 0, 0, ""
|
||||
return "", 0, ""
|
||||
login = data.get("claudeAiOauth")
|
||||
if not isinstance(login, dict):
|
||||
return "", 0, 0, ""
|
||||
# The profile's tier is asked first, but only one that names a multiplier
|
||||
# outranks the credentials'. The subscription still leads the label, so a
|
||||
# Team seat keeps reading Team.
|
||||
tier = profile_tier(claude_dir)
|
||||
if not re.search(r"max_(\d+x)", tier, re.IGNORECASE):
|
||||
tier = str(login.get("rateLimitTier") or "")
|
||||
plan = plan_label(tier, str(login.get("subscriptionType") or ""))
|
||||
return (
|
||||
str(login.get("accessToken") or ""),
|
||||
number(login.get("expiresAt")),
|
||||
number(login.get("refreshTokenExpiresAt")),
|
||||
plan,
|
||||
)
|
||||
return "", 0, ""
|
||||
plan = plan_label(str(login.get("rateLimitTier") or ""), str(login.get("subscriptionType") or ""))
|
||||
return str(login.get("accessToken") or ""), number(login.get("expiresAt")), plan
|
||||
|
||||
|
||||
# The credentials carry the tier their token was minted with, and an upgrade
|
||||
# does not restamp it: a refresh rewrites the file and keeps the old value, so
|
||||
# an account moved to Max 20x keeps reading Max 5x. The CLI's own profile is
|
||||
# refreshed against the account, so the tier it states there is asked first.
|
||||
def profile_tier(claude_dir: Path) -> str:
|
||||
try:
|
||||
account = json.loads(profile_path(claude_dir).read_text(encoding="utf-8")).get("oauthAccount")
|
||||
except Exception:
|
||||
return ""
|
||||
if not isinstance(account, dict):
|
||||
return ""
|
||||
# A seat with no tier of its own is limited by its organization, which is
|
||||
# the shape a personal Max subscription takes.
|
||||
for key in ("userRateLimitTier", "organizationRateLimitTier"):
|
||||
tier = str(account.get(key) or "")
|
||||
if tier:
|
||||
return tier
|
||||
return ""
|
||||
|
||||
|
||||
# The CLI's profile for a config home: a legacy .config.json inside it, else
|
||||
# ~/.claude.json for the default home and <home>/.claude.json for one it was
|
||||
# pointed at with CLAUDE_CONFIG_DIR. A copy anywhere else is never refreshed,
|
||||
# and another home's profile describes another account.
|
||||
def profile_path(home: Path) -> Path:
|
||||
legacy = home / ".config.json"
|
||||
if legacy.exists():
|
||||
return legacy
|
||||
configured = os.environ.get("CLAUDE_CONFIG_DIR")
|
||||
if home == expand_path("~/.claude") and not (configured and expand_path(configured) == home):
|
||||
return Path.home() / ".claude.json"
|
||||
return home / ".claude.json"
|
||||
|
||||
|
||||
# The rate-limit tier names the ceiling, not the plan: a Team premium seat
|
||||
# runs on `default_claude_max_5x` while paying for Team. Lead with the
|
||||
# subscription and keep the multiplier as its qualifier, so Max still reads
|
||||
# "Max 5x" and a Team seat reads "Team 5x" rather than passing as Max.
|
||||
def plan_label(tier: str, subscription: str) -> str:
|
||||
plan = subscription[0].upper() + subscription[1:] if subscription else ""
|
||||
match = re.search(r"max_(\d+x)", tier, re.IGNORECASE) if tier else None
|
||||
if not match:
|
||||
return plan
|
||||
return (plan or "Max") + " " + match.group(1)
|
||||
if tier:
|
||||
match = re.search(r"max_(\d+x)", tier, re.IGNORECASE)
|
||||
if match:
|
||||
return "Max " + match.group(1)
|
||||
if subscription:
|
||||
return subscription[0].upper() + subscription[1:]
|
||||
return ""
|
||||
|
||||
|
||||
def parse_utilization(value: Any) -> float:
|
||||
@@ -995,12 +723,11 @@ def probe_limits(access_token: str) -> dict[str, Any]:
|
||||
except urllib.error.HTTPError as error:
|
||||
retry_after = error.headers.get("retry-after", "") if error.headers else ""
|
||||
if error.code == 429:
|
||||
if retry_after:
|
||||
help_text = text("Anthropic's usage endpoint is rate limiting checks right now (retry after %1s). Local Claude Code stats are still shown.", retry_after)
|
||||
else:
|
||||
help_text = text("Anthropic's usage endpoint is rate limiting checks right now. Local Claude Code stats are still shown.")
|
||||
help_text = "Anthropic's usage endpoint is rate limiting checks right now" + (
|
||||
f" (retry after {retry_after}s)" if retry_after else ""
|
||||
) + ". Local Claude Code stats are still shown."
|
||||
else:
|
||||
help_text = text("Anthropic's usage endpoint returned status %1. Local Claude Code stats are still shown.", error.code)
|
||||
help_text = f"Anthropic's usage endpoint returned status {error.code}. Local Claude Code stats are still shown."
|
||||
return {"ok": False, "helpText": help_text}
|
||||
except Exception:
|
||||
# A transport failure reached no server at all — no route, no DNS. Any
|
||||
@@ -1038,8 +765,11 @@ def probe_limits(access_token: str) -> dict[str, Any]:
|
||||
return {"ok": True, "limits": limits}
|
||||
|
||||
|
||||
# Cached windows reset to zero when their measured period ends. Without a
|
||||
# fresh probe, the next rolling window has no known reset time yet.
|
||||
# A cached percentage outlives the probe that measured it, but only until its
|
||||
# window rolls over: once a window has reset, the figure describes a period
|
||||
# that is over, and a stale 78% would misreport an allowance that is now
|
||||
# untouched. A window with no reset time, or one that will not parse, is kept
|
||||
# — an unreadable timestamp is no reason to throw away a real number.
|
||||
def limit_window_open(entry: dict[str, Any], now: dt.datetime) -> bool:
|
||||
raw = str(entry.get("resetsAt") or "")
|
||||
if raw == "":
|
||||
@@ -1058,54 +788,30 @@ def usable_cached_limits(cached: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
if not isinstance(entries, list):
|
||||
return []
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
return [
|
||||
entry if limit_window_open(entry, now) else dict(entry, percent=0.0, resetsAt="")
|
||||
for entry in entries if isinstance(entry, dict)
|
||||
]
|
||||
return [entry for entry in entries if isinstance(entry, dict) and limit_window_open(entry, now)]
|
||||
|
||||
|
||||
def collect_limits(access_token: str, expires_at_ms: int, refresh_expires_at_ms: int, force: bool, cache_name: str = "claude-limits.json") -> dict[str, Any]:
|
||||
# `live` says the numbers came from Anthropic just now (or within the reuse
|
||||
# window) rather than from a cache kept past a failed or impossible probe.
|
||||
result = {"limits": [], "usageStatusText": "", "authHelpText": AUTH_HELP, "live": False}
|
||||
|
||||
# When the numbers were measured, so a stale account can say how old
|
||||
# they are.
|
||||
result["fetchedAtMs"] = 0
|
||||
def collect_limits(access_token: str, expires_at_ms: int, force: bool) -> dict[str, Any]:
|
||||
result = {"limits": [], "usageStatusText": "", "authHelpText": AUTH_HELP}
|
||||
|
||||
# A panel that is opened and shut repeatedly must not turn into a request
|
||||
# per flick, so recent probe results are reused for a short window — and
|
||||
# kept as the answer of record when a later probe fails. Every account
|
||||
# keeps its own, so one account's allowance never stands in for another's.
|
||||
probe_cache = cache_root() / cache_name
|
||||
cached = read_json(probe_cache) or {}
|
||||
# kept as the answer of record when a later probe fails.
|
||||
probe_cache = cache_root() / "claude-limits.json"
|
||||
cached = read_fresh_json(probe_cache, float("inf")) or {}
|
||||
fallback = usable_cached_limits(cached)
|
||||
result["fetchedAtMs"] = number(cached.get("fetchedAtMs"))
|
||||
|
||||
# Probing needs a live token and only the Claude Code CLI can mint one: it
|
||||
# refreshes the credential file when it runs, so a machine left alone long
|
||||
# enough finds the saved token lapsed. Say so — an empty limits list with
|
||||
# nothing else set hides the whole section and explains nothing — and keep
|
||||
# showing the last numbers, resetting elapsed windows to zero.
|
||||
# showing the last numbers whose window has not since reset.
|
||||
if access_token == "":
|
||||
result["limits"] = fallback
|
||||
result["usageStatusText"] = "Waiting for auth"
|
||||
return result
|
||||
if expires_at_ms > 0 and expires_at_ms <= time.time() * 1000:
|
||||
result["limits"] = fallback
|
||||
# A lapsed access token is routine, not a sign-out: it lives about eight
|
||||
# hours, and the CLI mints a new one from the refresh token the next time
|
||||
# it runs. Only a lapsed *refresh* token is a sign-in a person must redo,
|
||||
# and saying so either way sends people to `claude auth login` nightly --
|
||||
# which does not restore anything the CLI would not have restored itself.
|
||||
if refresh_expires_at_ms > time.time() * 1000:
|
||||
result["usageStatusText"] = "Limits paused"
|
||||
result["authHelpText"] = (
|
||||
"Claude Code's access token has lapsed"
|
||||
+ (" — showing the last known limits." if fallback else ".")
|
||||
+ " Start Claude Code to refresh it; signing in again is not needed."
|
||||
)
|
||||
return result
|
||||
result["usageStatusText"] = "Sign-in expired"
|
||||
result["authHelpText"] = (
|
||||
"Claude Code's saved sign-in expired"
|
||||
@@ -1118,20 +824,14 @@ def collect_limits(access_token: str, expires_at_ms: int, refresh_expires_at_ms:
|
||||
# entirely; the interval is there to absorb repeated panel opens, not to
|
||||
# overrule someone who pressed refresh.
|
||||
fetched_at = number(cached.get("fetchedAtMs")) / 1000
|
||||
fetched_age = time.time() - fetched_at
|
||||
if fallback and not force and 0 <= fetched_age < PROBE_MIN_INTERVAL_SECONDS:
|
||||
if fallback and not force and time.time() - fetched_at < PROBE_MIN_INTERVAL_SECONDS:
|
||||
result["limits"] = fallback
|
||||
result["live"] = True
|
||||
result["authHelpText"] = ""
|
||||
return result
|
||||
|
||||
probe = probe_limits(access_token)
|
||||
if probe["ok"]:
|
||||
result["limits"] = probe["limits"]
|
||||
result["live"] = True
|
||||
result["authHelpText"] = ""
|
||||
result["fetchedAtMs"] = round(time.time() * 1000)
|
||||
write_json(probe_cache, {"fetchedAtMs": result["fetchedAtMs"], "limits": probe["limits"]})
|
||||
write_json(probe_cache, {"fetchedAtMs": round(time.time() * 1000), "limits": probe["limits"]})
|
||||
return result
|
||||
|
||||
# The first probe after login often fires before DHCP has handed out a
|
||||
@@ -1140,95 +840,25 @@ def collect_limits(access_token: str, expires_at_ms: int, refresh_expires_at_ms:
|
||||
result["retryAdvised"] = True
|
||||
if fallback:
|
||||
result["limits"] = fallback
|
||||
fetched_age = time.time() - result["fetchedAtMs"] / 1000
|
||||
result["live"] = 0 <= fetched_age < CURRENT_ENOUGH_SECONDS
|
||||
else:
|
||||
result["usageStatusText"] = "Claude limits unavailable"
|
||||
result["authHelpText"] = probe["helpText"]
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- accounts
|
||||
|
||||
|
||||
# The accounts `omarchy agent account` registered, read straight from its
|
||||
# registry. Only a registry holding a second account matters: with one, the
|
||||
# record is exactly what it always was.
|
||||
def registered_accounts() -> list[dict[str, Any]]:
|
||||
state = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state")
|
||||
try:
|
||||
registry = json.loads((state / "omarchy" / "agents" / "accounts" / "claude.json").read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
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 current_account_id(home: Path) -> str:
|
||||
try:
|
||||
account = json.loads(profile_path(home).read_text(encoding="utf-8")).get("oauthAccount") or {}
|
||||
except Exception:
|
||||
return ""
|
||||
return str(account.get("accountUuid") or "")
|
||||
|
||||
|
||||
def account_limits(account: dict[str, Any], force: bool) -> dict[str, Any]:
|
||||
home = expand_path(account["home"]) if account.get("home") else expand_path("~/.claude")
|
||||
access_token, expires_at_ms, refresh_expires_at_ms, plan = oauth_login(home)
|
||||
# Keyed by the subscription, not the label: forget "Work" and add a different
|
||||
# "Work", and the new one must never fall back on the old one's allowance.
|
||||
# Who the home is signed in as now, not when it was added: a `claude auth
|
||||
# login` run in it by hand makes it a different subscription.
|
||||
identity = current_account_id(home)
|
||||
key = re.sub(r"[^A-Za-z0-9_-]", "", identity or str(account.get("accountId") or "")) or account["id"]
|
||||
# The primary home is keyed the same way once it says who it's signed in
|
||||
# as, so signing it in to another subscription never inherits the last
|
||||
# one's numbers; until then it keeps the single-account cache.
|
||||
if account.get("primary") and not identity:
|
||||
cache_name = "claude-limits.json"
|
||||
else:
|
||||
cache_name = f"claude-limits-{key}.json"
|
||||
limits = collect_limits(access_token, expires_at_ms, refresh_expires_at_ms, force, cache_name)
|
||||
return {
|
||||
"id": account["id"],
|
||||
"label": str(account.get("label") or account["id"]),
|
||||
"email": str(account.get("email") or ""),
|
||||
"plan": plan 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"],
|
||||
"retryAdvised": bool(limits.get("retryAdvised")),
|
||||
}
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- record
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--force", action="store_true", help="rescan transcripts from scratch and re-probe limits, ignoring caches")
|
||||
parser.add_argument("--force", action="store_true", help="rescan transcripts and re-probe limits, ignoring caches")
|
||||
parser.add_argument("--limits-only", action="store_true", help="reuse any recent transcript scan; only the limits probe must be fresh")
|
||||
parser.add_argument("--cache-seconds", type=float, default=20)
|
||||
args = parser.parse_args()
|
||||
|
||||
claude_dir = config_dir()
|
||||
scan_age = 0 if args.force else (900 if args.limits_only else args.cache_seconds)
|
||||
stats = cached_scan(claude_dir / "projects", scan_age, use_index=not args.force)
|
||||
stats = cached_scan(claude_dir / "projects", scan_age)
|
||||
|
||||
if number(stats.get("totalPrompts")) <= 0:
|
||||
fallback = stats_cache_fallback(claude_dir)
|
||||
@@ -1249,19 +879,8 @@ def main() -> int:
|
||||
if opencode is not None:
|
||||
stats = merge_stats(stats, opencode)
|
||||
|
||||
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:
|
||||
# The top-level fields keep describing the account new sessions use, so
|
||||
# the bar icon and anything else reading one set of limits stays right.
|
||||
plan = current["plan"]
|
||||
limits = {key: current[key] for key in ("limits", "usageStatusText", "authHelpText", "retryAdvised")}
|
||||
stale, fetched_at = current["stale"], current["fetchedAt"]
|
||||
else:
|
||||
access_token, expires_at_ms, refresh_expires_at_ms, plan = oauth_login(claude_dir)
|
||||
limits = collect_limits(access_token, expires_at_ms, refresh_expires_at_ms, args.force)
|
||||
stale, fetched_at = not limits["live"], limits["fetchedAtMs"]
|
||||
access_token, expires_at_ms, plan = oauth_login(claude_dir)
|
||||
limits = collect_limits(access_token, expires_at_ms, args.force)
|
||||
|
||||
record = {
|
||||
"schemaVersion": 1,
|
||||
@@ -1274,16 +893,9 @@ def main() -> int:
|
||||
"usageStatusText": limits["usageStatusText"],
|
||||
"authHelpText": limits["authHelpText"],
|
||||
"limits": limits["limits"],
|
||||
# Numbers kept past a failed probe, and when they were measured, so the
|
||||
# panel can tell them apart from ones checked just now.
|
||||
"limitsStale": stale,
|
||||
"limitsFetchedAt": fetched_at,
|
||||
}
|
||||
if limits.get("retryAdvised"):
|
||||
record["retryAdvised"] = True
|
||||
if accounts:
|
||||
record["accountSwitch"] = registered[0]["switch"]
|
||||
record["accounts"] = accounts
|
||||
record.update(stats)
|
||||
print(json.dumps(record, separators=(",", ":"), sort_keys=True))
|
||||
return 0
|
||||
|
||||
+109
-583
@@ -22,7 +22,6 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import tomllib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -30,11 +29,6 @@ AGENT_ID = "codex"
|
||||
AGENT_NAME = "Codex"
|
||||
AUTH_HELP = "Run `codex login` to authenticate."
|
||||
|
||||
# session_meta.model_provider for the built-in OpenAI backend, which is the
|
||||
# only one this subscription pays for. Any other id points somewhere else:
|
||||
# `--oss`'s ollama or lmstudio, or a custom provider in config.toml.
|
||||
NATIVE_PROVIDER = "openai"
|
||||
|
||||
# A scan this recent is only reused to dedup concurrent collector runs (the
|
||||
# update command backgrounds one per agent while the panel refreshes on its
|
||||
# own); every periodic widget refresh lands a real rescan, however low
|
||||
@@ -43,11 +37,6 @@ NATIVE_PROVIDER = "openai"
|
||||
SCAN_REUSE_SECONDS = 20
|
||||
LIMITS_ONLY_REUSE_SECONDS = 900
|
||||
|
||||
# Per-file totals of native Codex sessions survive between runs, so a rescan
|
||||
# only reads the session files that changed. Bumping the schema retires every
|
||||
# record written by an older layout.
|
||||
FILE_CACHE_SCHEMA = 1
|
||||
|
||||
|
||||
def local_day(value):
|
||||
if value is None:
|
||||
@@ -102,52 +91,6 @@ def find_command(name):
|
||||
return shutil.which(name, path=ENV.get("PATH"))
|
||||
|
||||
|
||||
def mise_shims_dir():
|
||||
data_home = ENV.get("MISE_DATA_DIR") or os.path.join(
|
||||
ENV.get("XDG_DATA_HOME") or os.path.join(str(Path.home()), ".local", "share"), "mise")
|
||||
return os.path.join(data_home, "shims")
|
||||
|
||||
|
||||
def is_lazy_launcher(path):
|
||||
# omarchy-mise-install writes a regular file whose first step is `mise use
|
||||
# -g`, and mise's shims exec `mise x`; running either installs the tool it
|
||||
# wraps. A shim is a symlink to the mise binary itself, so the symlink
|
||||
# check comes after the shim checks: a symlink elsewhere is the user's own
|
||||
# binary.
|
||||
if os.path.dirname(os.path.abspath(path)) == mise_shims_dir():
|
||||
return True
|
||||
if os.path.islink(path):
|
||||
mise = find_command("mise")
|
||||
return bool(mise) and os.path.realpath(path) == os.path.realpath(mise)
|
||||
try:
|
||||
with open(path, "rb") as handle:
|
||||
head = handle.read(4096)
|
||||
return b"mise use" in head or b"mise x " in head
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def find_codex_binary():
|
||||
for directory in ENV.get("PATH", "").split(os.pathsep):
|
||||
candidate = os.path.join(directory, "codex")
|
||||
if os.path.isfile(candidate) and os.access(candidate, os.X_OK) and not is_lazy_launcher(candidate):
|
||||
return candidate
|
||||
# `mise which` only prints a binary that is already installed, so it is the
|
||||
# safe way to resolve a codex managed by mise without running the launcher.
|
||||
# `mise which` can resolve a configured `latest` over the network; the usage
|
||||
# probe stays offline.
|
||||
mise = find_command("mise")
|
||||
if mise:
|
||||
try:
|
||||
out = subprocess.run([mise, "which", "codex"], capture_output=True, text=True, env=dict(ENV, MISE_OFFLINE="1"), timeout=10)
|
||||
resolved = out.stdout.strip()
|
||||
if out.returncode == 0 and os.path.isfile(resolved) and not is_lazy_launcher(resolved):
|
||||
return resolved
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
now = datetime.now()
|
||||
today = now.strftime("%Y-%m-%d")
|
||||
recent_dates = [(now - timedelta(days=offset)).strftime("%Y-%m-%d") for offset in range(6, -1, -1)]
|
||||
@@ -164,109 +107,46 @@ total_sessions = set()
|
||||
seen_pi_messages = set()
|
||||
|
||||
|
||||
# One session file's totals, the unit the file cache stores: day -> model ->
|
||||
# [input, output, cacheRead, cacheWrite, prompts]. Day-keyed and with no
|
||||
# "today" in it, so a record stays true however long it is kept.
|
||||
def new_file_record(stat_result):
|
||||
return {"mtime": stat_result.st_mtime, "size": stat_result.st_size, "days": {}}
|
||||
|
||||
|
||||
def record_usage(record, day, model, input_tokens, output_tokens, cache_read, cache_write):
|
||||
totals = record["days"].setdefault(day, {}).setdefault(model, [0, 0, 0, 0, 0])
|
||||
totals[0] += input_tokens
|
||||
totals[1] += output_tokens
|
||||
totals[2] += cache_read
|
||||
totals[3] += cache_write
|
||||
totals[4] += 1
|
||||
|
||||
|
||||
def merge_file_record(session_key, record):
|
||||
"""Fold one file's totals, freshly read or replayed from cache, into the run."""
|
||||
global today_prompts, today_total_tokens, total_prompts
|
||||
for day, models in record["days"].items():
|
||||
for model, totals in models.items():
|
||||
input_tokens, output_tokens, cache_read, cache_write, prompts = totals
|
||||
total = input_tokens + output_tokens + cache_read + cache_write
|
||||
total_prompts += prompts
|
||||
total_sessions.add(session_key)
|
||||
active_days.add(day)
|
||||
|
||||
bucket = model_usage.setdefault(model, {
|
||||
"inputTokens": 0,
|
||||
"outputTokens": 0,
|
||||
"cacheReadInputTokens": 0,
|
||||
"cacheCreationInputTokens": 0,
|
||||
})
|
||||
bucket["inputTokens"] += input_tokens
|
||||
bucket["outputTokens"] += output_tokens
|
||||
bucket["cacheReadInputTokens"] += cache_read
|
||||
bucket["cacheCreationInputTokens"] += cache_write
|
||||
|
||||
if day in recent:
|
||||
recent[day]["messageCount"] += total
|
||||
|
||||
if day == today:
|
||||
today_prompts += prompts
|
||||
today_sessions.add(session_key)
|
||||
today_total_tokens += total
|
||||
today_tokens_by_model[model] = today_tokens_by_model.get(model, 0) + total
|
||||
|
||||
|
||||
def add_usage(day, session_key, model, input_tokens, output_tokens, cache_read, cache_write):
|
||||
record = {"days": {}}
|
||||
record_usage(record, day, model, input_tokens, output_tokens, cache_read, cache_write)
|
||||
merge_file_record(session_key, record)
|
||||
global today_prompts, today_total_tokens, total_prompts
|
||||
total = input_tokens + output_tokens + cache_read + cache_write
|
||||
total_prompts += 1
|
||||
total_sessions.add(session_key)
|
||||
active_days.add(day)
|
||||
|
||||
bucket = model_usage.setdefault(model, {
|
||||
"inputTokens": 0,
|
||||
"outputTokens": 0,
|
||||
"cacheReadInputTokens": 0,
|
||||
"cacheCreationInputTokens": 0,
|
||||
})
|
||||
bucket["inputTokens"] += input_tokens
|
||||
bucket["outputTokens"] += output_tokens
|
||||
bucket["cacheReadInputTokens"] += cache_read
|
||||
bucket["cacheCreationInputTokens"] += cache_write
|
||||
|
||||
def pi_parent_session(path):
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as session_file:
|
||||
header = json.loads(session_file.readline())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if day in recent:
|
||||
recent[day]["messageCount"] += total
|
||||
|
||||
if not isinstance(header, dict):
|
||||
return None
|
||||
parent = header.get("parentSession")
|
||||
if not isinstance(parent, str) or not parent:
|
||||
return None
|
||||
return os.path.abspath(os.path.expanduser(parent))
|
||||
|
||||
|
||||
# `omp --profile=<name>` (and pi's equivalent) relocates the whole agent tree
|
||||
# under <base>/profiles/<name>/, so a subscription driven entirely through a
|
||||
# profile leaves the default root empty and its usage uncounted. Each root is
|
||||
# scanned separately and sessions are keyed by path, so a profile adds
|
||||
# sessions rather than double-counting the default one.
|
||||
def pi_session_roots():
|
||||
roots = []
|
||||
for base in (Path.home() / ".pi", Path.home() / ".omp"):
|
||||
roots.append(base / "agent" / "sessions")
|
||||
profiles = base / "profiles"
|
||||
try:
|
||||
# Sorted so the scan order does not depend on directory order.
|
||||
roots.extend(sorted(child / "agent" / "sessions" for child in profiles.iterdir() if child.is_dir()))
|
||||
except OSError:
|
||||
# No profiles directory, or it is unreadable: the default root stands.
|
||||
pass
|
||||
return roots
|
||||
if day == today:
|
||||
today_prompts += 1
|
||||
today_sessions.add(session_key)
|
||||
today_total_tokens += total
|
||||
today_tokens_by_model[model] = today_tokens_by_model.get(model, 0) + total
|
||||
|
||||
|
||||
def scan_pi_sessions():
|
||||
roots = pi_session_roots()
|
||||
roots = [
|
||||
Path.home() / ".pi" / "agent" / "sessions",
|
||||
Path.home() / ".omp" / "agent" / "sessions",
|
||||
]
|
||||
rg = find_command("rg") or "rg"
|
||||
session_keys = {}
|
||||
today_session_keys = {}
|
||||
for root in roots:
|
||||
if not root.exists():
|
||||
continue
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
# --no-ignore: these are data files, not a source tree. When $HOME is
|
||||
# itself a git checkout (a common dotfiles setup), the parent repo's
|
||||
# ignore rules would otherwise silently exclude every session file and
|
||||
# the scan would count zero usage.
|
||||
[rg, "--json", "--no-ignore", "-e", r'"provider"\s*:\s*"openai-codex"', "-e", r'"api"\s*:\s*"openai-codex', str(root)],
|
||||
[rg, "--json", "-e", r'"provider"\s*:\s*"openai-codex"', "-e", r'"api"\s*:\s*"openai-codex', str(root)],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
@@ -283,22 +163,16 @@ def scan_pi_sessions():
|
||||
if event.get("type") != "match":
|
||||
continue
|
||||
line = event.get("data", {}).get("lines", {}).get("text", "")
|
||||
raw_path = event.get("data", {}).get("path", {}).get("text", "pi-session")
|
||||
path = os.path.abspath(os.path.expanduser(raw_path))
|
||||
path = event.get("data", {}).get("path", {}).get("text", "pi-session")
|
||||
entry = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if entry.get("type") != "message":
|
||||
continue
|
||||
message_id = str(entry.get("id") or "")
|
||||
message_timestamp = str(entry.get("timestamp") or "")
|
||||
# Forks retain both fields; IDs alone can collide across sessions.
|
||||
if message_id and message_timestamp:
|
||||
message_key = ("entry", message_id, message_timestamp)
|
||||
else:
|
||||
message_key = ("path", path, message_id)
|
||||
duplicate = message_key in seen_pi_messages
|
||||
message_key = path + ":" + str(entry.get("id") or "")
|
||||
if message_key in seen_pi_messages:
|
||||
continue
|
||||
seen_pi_messages.add(message_key)
|
||||
message = entry.get("message") or {}
|
||||
if message.get("role") != "assistant":
|
||||
@@ -322,37 +196,14 @@ def scan_pi_sessions():
|
||||
continue
|
||||
|
||||
day = local_day(entry.get("timestamp") or message.get("timestamp"))
|
||||
session_keys.setdefault(path, set()).add(message_key)
|
||||
if day == today:
|
||||
today_session_keys.setdefault(path, set()).add(message_key)
|
||||
if duplicate:
|
||||
continue
|
||||
add_usage(day, path, model_name(message.get("model")), input_tokens, output_tokens, cache_read, cache_write)
|
||||
session_key = path
|
||||
add_usage(day, session_key, model_name(message.get("model")), input_tokens, output_tokens, cache_read, cache_write)
|
||||
|
||||
try:
|
||||
proc.wait(timeout=1)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
|
||||
# Resolve session ownership after scanning so rg's file order cannot affect it.
|
||||
parents = {path: pi_parent_session(path) for path in session_keys}
|
||||
total_sessions.difference_update(session_keys)
|
||||
today_sessions.difference_update(session_keys)
|
||||
for path, keys in session_keys.items():
|
||||
inherited = set()
|
||||
visited = {path}
|
||||
parent = parents.get(path)
|
||||
while parent and parent not in visited:
|
||||
visited.add(parent)
|
||||
inherited.update(session_keys.get(parent, ()))
|
||||
parent = parents.get(parent)
|
||||
|
||||
unique_keys = keys - inherited
|
||||
if unique_keys:
|
||||
total_sessions.add(path)
|
||||
if unique_keys & today_session_keys.get(path, set()):
|
||||
today_sessions.add(path)
|
||||
|
||||
|
||||
def scan_opencode_sessions():
|
||||
# A subscription burned entirely through opencode leaves no native session
|
||||
@@ -432,7 +283,7 @@ def scan_opencode_sessions():
|
||||
return True
|
||||
|
||||
|
||||
def scan_native_codex_sessions(cached_files, scanned_files):
|
||||
def scan_native_codex_sessions():
|
||||
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
||||
roots = [codex_home / "sessions", codex_home / "archived_sessions"]
|
||||
files = []
|
||||
@@ -442,98 +293,48 @@ def scan_native_codex_sessions(cached_files, scanned_files):
|
||||
continue
|
||||
for path in root.rglob("*.jsonl"):
|
||||
try:
|
||||
stat_result = path.stat()
|
||||
if path.stat().st_mtime >= cutoff:
|
||||
files.append(path)
|
||||
except OSError:
|
||||
continue
|
||||
if stat_result.st_mtime >= cutoff:
|
||||
files.append((path, stat_result))
|
||||
pass
|
||||
|
||||
for path, stat_result in files:
|
||||
key = str(path)
|
||||
record = reusable_record(cached_files, key, stat_result)
|
||||
if record is None:
|
||||
record = read_native_codex_session(path, stat_result)
|
||||
if record["mtime"] is not None:
|
||||
scanned_files[key] = record
|
||||
merge_file_record(key, record)
|
||||
|
||||
|
||||
def read_native_codex_session(path, stat_result):
|
||||
record = new_file_record(stat_result)
|
||||
current_model = "codex"
|
||||
seen_meta = False
|
||||
previous_total_usage = None
|
||||
try:
|
||||
with path.open(errors="replace") as handle:
|
||||
for raw in handle:
|
||||
# Cheap pre-filter before JSON parsing keeps files with unrelated
|
||||
# lines inexpensive. session_meta still has to get through: it says
|
||||
# which provider served the rollout.
|
||||
if '"token_count"' not in raw and '"turn_context"' not in raw and '"session_meta"' not in raw:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
if entry.get("type") == "session_meta":
|
||||
# A forked rollout copies its parent's session_meta after its own,
|
||||
# so only the first one, as Codex reads it, says who served this.
|
||||
if seen_meta:
|
||||
for path in files:
|
||||
current_model = "codex"
|
||||
try:
|
||||
with path.open(errors="replace") as handle:
|
||||
for raw in handle:
|
||||
try:
|
||||
entry = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
seen_meta = True
|
||||
# Codex CLI happily fronts any OpenAI-compatible backend (`--oss`,
|
||||
# or a custom model_provider in config.toml). Those turns bill the
|
||||
# local box or a third party, never this subscription, so drop the
|
||||
# whole rollout the way the pi and opencode scans drop foreign
|
||||
# providers.
|
||||
meta = entry.get("payload") or {}
|
||||
provider = str(meta.get("model_provider") or "")
|
||||
# Rollouts predating the field carry no provider at all; count
|
||||
# those rather than silently lose the history.
|
||||
if provider and provider != NATIVE_PROVIDER:
|
||||
break
|
||||
continue
|
||||
if entry.get("type") == "turn_context":
|
||||
payload = entry.get("payload") or {}
|
||||
current_model = model_name(payload.get("model") or payload.get("model_slug") or current_model)
|
||||
continue
|
||||
payload = entry.get("payload") or entry
|
||||
if entry.get("type") == "response_item" and isinstance(payload, dict):
|
||||
payload = payload.get("payload") or payload
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
if payload.get("type") != "token_count":
|
||||
continue
|
||||
info = payload.get("info") or {}
|
||||
# total_token_usage is cumulative for the session. Adding every
|
||||
# snapshot makes usage grow quadratically, so count the last turn.
|
||||
usage = info.get("last_token_usage") or {}
|
||||
cache_read = number(usage.get("cached_input_tokens"))
|
||||
cache_write = number(usage.get("cache_write_input_tokens"))
|
||||
# Cached tokens are included in input_tokens, and reasoning tokens
|
||||
# are included in output_tokens. Keep the cache split without
|
||||
# counting either category twice.
|
||||
input_tokens = max(0, number(usage.get("input_tokens")) - cache_read - cache_write)
|
||||
output_tokens = number(usage.get("output_tokens"))
|
||||
if not (input_tokens or output_tokens or cache_read or cache_write):
|
||||
continue
|
||||
# Quota updates can repeat the previous request's last_token_usage.
|
||||
# Only an unchanged cumulative snapshot proves this is a repeat:
|
||||
# separate requests may have identical last-token counts, and older
|
||||
# records may not carry cumulative counters at all. Keep this state
|
||||
# per rollout and accept changed counters, including counter resets.
|
||||
total_usage = info.get("total_token_usage")
|
||||
if isinstance(total_usage, dict) and total_usage:
|
||||
if total_usage == previous_total_usage:
|
||||
if entry.get("type") == "turn_context":
|
||||
payload = entry.get("payload") or {}
|
||||
current_model = model_name(payload.get("model") or payload.get("model_slug") or current_model)
|
||||
continue
|
||||
previous_total_usage = total_usage
|
||||
day = local_day(entry.get("timestamp") or stat_result.st_mtime)
|
||||
record_usage(record, day, current_model, input_tokens, output_tokens, cache_read, cache_write)
|
||||
except Exception:
|
||||
# Keep what was read before the failure, but caching a partial record would
|
||||
# hide the rest until the file changes, so hand back one the cache refuses.
|
||||
record["mtime"] = None
|
||||
return record
|
||||
payload = entry.get("payload") or entry
|
||||
if entry.get("type") == "response_item" and isinstance(payload, dict):
|
||||
payload = payload.get("payload") or payload
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
if payload.get("type") != "token_count":
|
||||
continue
|
||||
info = payload.get("info") or {}
|
||||
# total_token_usage is cumulative for the session. Adding every
|
||||
# snapshot makes usage grow quadratically, so count the last turn.
|
||||
usage = info.get("last_token_usage") or {}
|
||||
cache_read = number(usage.get("cached_input_tokens"))
|
||||
cache_write = number(usage.get("cache_write_input_tokens"))
|
||||
# Cached tokens are included in input_tokens, and reasoning tokens
|
||||
# are included in output_tokens. Keep the cache split without
|
||||
# counting either category twice.
|
||||
input_tokens = max(0, number(usage.get("input_tokens")) - cache_read - cache_write)
|
||||
output_tokens = number(usage.get("output_tokens"))
|
||||
if not (input_tokens or output_tokens or cache_read or cache_write):
|
||||
continue
|
||||
day = local_day(entry.get("timestamp") or path.stat().st_mtime)
|
||||
add_usage(day, str(path), current_model, input_tokens, output_tokens, cache_read, cache_write)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
def cache_root():
|
||||
@@ -542,86 +343,16 @@ def cache_root():
|
||||
return root
|
||||
|
||||
|
||||
def scan_cache_digest():
|
||||
def scan_cache_paths():
|
||||
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
||||
db = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share")) / "opencode" / "opencode.db"
|
||||
# The digest covers every data path the scan reads: the codex session
|
||||
# roots, the opencode DB, and (via Path.home()) the pi/omp session roots.
|
||||
return hashlib.sha1((str(Path.home()) + "\n" + str(codex_home) + "\n" + str(db)).encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def scan_cache_paths():
|
||||
digest = scan_cache_digest()
|
||||
digest = hashlib.sha1((str(Path.home()) + "\n" + str(codex_home) + "\n" + str(db)).encode("utf-8")).hexdigest()[:16]
|
||||
root = cache_root()
|
||||
return root / f"codex-scan-{digest}.json", root / f"codex-scan-{digest}.lock"
|
||||
|
||||
|
||||
# Records hold local days, so they are only true in the timezone that wrote them.
|
||||
def file_cache_zone():
|
||||
return [os.environ.get("TZ"), list(time.tzname), time.timezone, time.altzone]
|
||||
|
||||
|
||||
def file_cache_path():
|
||||
return cache_root() / f"codex-files-{scan_cache_digest()}.json"
|
||||
|
||||
|
||||
def valid_file_record(value):
|
||||
try:
|
||||
counts = [totals for models in value["days"].values() for totals in models.values()]
|
||||
return (
|
||||
type(value["mtime"]) in (int, float) and type(value["size"]) is int
|
||||
and all(len(totals) == 5 and all(type(n) is int for n in totals) for totals in counts)
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def read_file_cache():
|
||||
"""Per-file totals of native sessions from earlier scans, keyed by path.
|
||||
|
||||
Session files are append-only, so a file whose mtime and size both match the
|
||||
record cannot have grown a turn since: its totals can be replayed instead of
|
||||
read. That is what keeps a refresh off the gigabytes of history it already
|
||||
counted. Unlike the aggregate scan cache these records carry no "today" and
|
||||
never expire on their own -- only the file they describe or a change of
|
||||
timezone can invalidate one, so midnight and the clock moving leave them true.
|
||||
|
||||
pi and omp sessions stay out of it: a forked pi session repeats its parent's
|
||||
messages, so they are only deduplicated across the whole tree at once.
|
||||
"""
|
||||
try:
|
||||
payload = json.loads(file_cache_path().read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
if not isinstance(payload, dict) or payload.get("schemaVersion") != FILE_CACHE_SCHEMA:
|
||||
return {}
|
||||
if payload.get("zone") != file_cache_zone():
|
||||
return {}
|
||||
files = payload.get("files")
|
||||
if not isinstance(files, dict):
|
||||
return {}
|
||||
return {path: record for path, record in files.items() if valid_file_record(record)}
|
||||
|
||||
|
||||
def write_file_cache(files):
|
||||
# Only the files this scan saw are written back, so deleted sessions and
|
||||
# sessions that aged out of the 30-day window leave the cache instead of
|
||||
# growing it forever.
|
||||
try:
|
||||
write_json(file_cache_path(), {"schemaVersion": FILE_CACHE_SCHEMA, "zone": file_cache_zone(), "files": files})
|
||||
except Exception as exc:
|
||||
print(f"omarchy-agent-usage-codex: could not write file cache ({exc})", file=sys.stderr)
|
||||
|
||||
|
||||
def reusable_record(cached_files, key, stat_result):
|
||||
record = cached_files.get(key)
|
||||
if record is None:
|
||||
return None
|
||||
if record["mtime"] != stat_result.st_mtime or record["size"] != stat_result.st_size:
|
||||
return None
|
||||
return record
|
||||
|
||||
|
||||
def read_fresh_json(path, max_age_seconds):
|
||||
if max_age_seconds <= 0 or not path.exists():
|
||||
return None
|
||||
@@ -658,10 +389,9 @@ def write_json(path, payload):
|
||||
# The cache payload is a versioned envelope around the local-stats dict, so a
|
||||
# corrupted or foreign-shaped file is a cache miss (rescan + rewrite) instead
|
||||
# of a crash or a garbage record.
|
||||
# Version 2 invalidates totals counted before native notification deduplication.
|
||||
def read_cached_stats(cache_file, max_age_seconds):
|
||||
cached = read_fresh_json(cache_file, max_age_seconds)
|
||||
if not isinstance(cached, dict) or cached.get("schemaVersion") != 2:
|
||||
if not isinstance(cached, dict) or cached.get("schemaVersion") != 1:
|
||||
return None
|
||||
# today* fields only mean "today" on the day they were scanned. A cache
|
||||
# from another local date (midnight passed, or the clock moved) is a miss,
|
||||
@@ -678,7 +408,7 @@ def read_cached_stats(cache_file, max_age_seconds):
|
||||
|
||||
def write_cached_stats(cache_file, stats):
|
||||
try:
|
||||
write_json(cache_file, {"schemaVersion": 2, "scanDate": today, "stats": stats})
|
||||
write_json(cache_file, {"schemaVersion": 1, "scanDate": today, "stats": stats})
|
||||
except Exception as exc:
|
||||
print(f"omarchy-agent-usage-codex: could not write usage cache ({exc})", file=sys.stderr)
|
||||
|
||||
@@ -702,14 +432,10 @@ def local_stats():
|
||||
}
|
||||
|
||||
|
||||
# A forced scan (no reuse at all) reads every session file again.
|
||||
def run_local_scans(max_age):
|
||||
cached_files = read_file_cache() if max_age > 0 else {}
|
||||
scanned_files = {}
|
||||
def run_local_scans():
|
||||
scan_pi_sessions()
|
||||
scan_native_codex_sessions(cached_files, scanned_files)
|
||||
scan_native_codex_sessions()
|
||||
complete = scan_opencode_sessions()
|
||||
write_file_cache(scanned_files)
|
||||
return local_stats(), complete
|
||||
|
||||
|
||||
@@ -724,7 +450,7 @@ def cached_local_stats(max_age):
|
||||
return _cached_local_stats(max_age)
|
||||
except Exception as exc:
|
||||
print(f"omarchy-agent-usage-codex: cache unavailable ({exc}); scanning directly", file=sys.stderr)
|
||||
stats, _ = run_local_scans(max_age)
|
||||
stats, _ = run_local_scans()
|
||||
return stats
|
||||
|
||||
|
||||
@@ -740,7 +466,7 @@ def _cached_local_stats(max_age):
|
||||
cached = read_cached_stats(cache_file, max_age)
|
||||
if cached is not None:
|
||||
return cached
|
||||
stats, complete = run_local_scans(max_age)
|
||||
stats, complete = run_local_scans()
|
||||
# An interrupted scan still serves this run, but caching it would
|
||||
# suppress the missing usage for every reader until the cache expires.
|
||||
if complete:
|
||||
@@ -748,55 +474,25 @@ def _cached_local_stats(max_age):
|
||||
return stats
|
||||
|
||||
|
||||
def rpc_send(proc, payload, operation):
|
||||
"""Write one JSON-RPC frame. Raise immediately if the app-server is gone."""
|
||||
try:
|
||||
proc.stdin.write(json.dumps(payload) + "\n")
|
||||
proc.stdin.flush()
|
||||
except OSError as exc:
|
||||
try:
|
||||
proc.stdin.close()
|
||||
except OSError:
|
||||
pass
|
||||
raise RuntimeError(f"Codex app-server exited before {operation}") from exc
|
||||
|
||||
|
||||
def rpc_request(proc, request_id, method, params=None, timeout=8):
|
||||
payload = {"id": request_id, "method": method, "params": params or {}}
|
||||
rpc_send(proc, payload, method)
|
||||
# Read the raw fd and split lines ourselves. The app-server can emit
|
||||
# notifications in the same write as a reply; a buffered readline() would
|
||||
# pull them all into Python's buffer where select() cannot see them, and
|
||||
# the reply would sit there until the deadline.
|
||||
fd = proc.stdout.fileno()
|
||||
pending = getattr(proc, "_rpc_pending", b"")
|
||||
deadline = time.monotonic() + timeout
|
||||
try:
|
||||
while True:
|
||||
while b"\n" in pending:
|
||||
line, pending = pending.split(b"\n", 1)
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(message, dict) and message.get("id") == request_id:
|
||||
return message
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
ready, _, _ = select.select([fd], [], [], min(0.25, remaining))
|
||||
if not ready:
|
||||
if proc.poll() is not None:
|
||||
raise RuntimeError(f"Codex app-server exited before {method}")
|
||||
continue
|
||||
chunk = os.read(fd, 65536)
|
||||
if not chunk:
|
||||
raise RuntimeError(f"Codex app-server exited before {method}")
|
||||
pending += chunk
|
||||
finally:
|
||||
proc._rpc_pending = pending
|
||||
# Process still up but silent: keep the method name searchable, but name the stall.
|
||||
raise TimeoutError(f"Codex app-server did not answer {method}")
|
||||
proc.stdin.write(json.dumps(payload) + "\n")
|
||||
proc.stdin.flush()
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
ready, _, _ = select.select([proc.stdout], [], [], 0.25)
|
||||
if not ready:
|
||||
continue
|
||||
line = proc.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if message.get("id") == request_id:
|
||||
return message
|
||||
raise TimeoutError(method)
|
||||
|
||||
|
||||
def limit_window(window):
|
||||
@@ -822,153 +518,47 @@ def limit_window(window):
|
||||
}
|
||||
|
||||
|
||||
def codex_rpc_help(proc, stderr_file, exc):
|
||||
"""Explain an app-server RPC failure: a live server's stall, a dead one's own
|
||||
stderr, or the login hint when it died saying nothing."""
|
||||
try:
|
||||
proc.wait(timeout=1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
text = str(exc).strip()
|
||||
if proc.poll() is None:
|
||||
# Still up: stall or unparseable payload. Not an auth problem, and any
|
||||
# stderr so far is its logging, not why it stopped.
|
||||
return text[:300] or AUTH_HELP
|
||||
|
||||
try:
|
||||
stderr_file.seek(0)
|
||||
detail = stderr_file.read()
|
||||
except Exception:
|
||||
detail = ""
|
||||
lines = [line.strip() for line in detail.splitlines() if line.strip()]
|
||||
if lines:
|
||||
# The fatal error comes last, after whatever the CLI logged on the way up.
|
||||
return f"codex app-server exited: {' '.join(lines)[-275:]}"
|
||||
# Exited cleanly with empty stderr — typical "not logged in" shape.
|
||||
return AUTH_HELP
|
||||
|
||||
|
||||
def reset_credits(result):
|
||||
granted = (result.get("rateLimitResetCredits") or {}).get("credits") or []
|
||||
available = [c for c in granted if isinstance(c, dict) and c.get("status") == "available"]
|
||||
if not available:
|
||||
return None
|
||||
expiries = [number(c.get("expiresAt")) for c in available if number(c.get("expiresAt")) > 0]
|
||||
return {
|
||||
"available": len(available),
|
||||
"nextExpiresAt": datetime.fromtimestamp(min(expiries), timezone.utc).isoformat() if expiries else "",
|
||||
}
|
||||
|
||||
|
||||
# Keyring and auto stores keep credentials outside auth.json, so only the file
|
||||
# store can be known to be empty without asking Codex.
|
||||
def has_codex_credentials(codex_home, env):
|
||||
if env.get("CODEX_ACCESS_TOKEN"):
|
||||
return True
|
||||
try:
|
||||
with (codex_home / "config.toml").open("rb") as handle:
|
||||
config = tomllib.load(handle)
|
||||
except FileNotFoundError:
|
||||
config = {}
|
||||
except Exception:
|
||||
# A config this can't read may still name another store; let Codex say.
|
||||
return True
|
||||
if str(config.get("cli_auth_credentials_store") or "file") != "file":
|
||||
return True
|
||||
return (codex_home / "auth.json").is_file()
|
||||
|
||||
|
||||
def fetch_codex_rpc(home=None):
|
||||
def fetch_codex_rpc():
|
||||
result = {"limits": [], "tierLabel": "", "usageStatusText": "", "authHelpText": AUTH_HELP}
|
||||
env = dict(ENV, CODEX_HOME=str(home)) if home else ENV
|
||||
codex = find_codex_binary()
|
||||
codex = find_command("codex")
|
||||
if not codex:
|
||||
result["usageStatusText"] = "Codex unavailable"
|
||||
result["authHelpText"] = "codex not found in PATH"
|
||||
return result
|
||||
|
||||
# No auth.json under the file store and no CODEX_ACCESS_TOKEN means
|
||||
# account/read can only fail — and starting the app-server is not free: it
|
||||
# syncs the plugin list, a git fetch per refresh that also leaves
|
||||
# .tmp/git-* folders behind. The home is the one being probed, so a
|
||||
# signed-out secondary account reads as signed out even while ~/.codex
|
||||
# holds a login.
|
||||
if not has_codex_credentials(Path(env.get("CODEX_HOME") or (Path.home() / ".codex")), env):
|
||||
result["usageStatusText"] = "Waiting for auth"
|
||||
result["authHelpText"] = AUTH_HELP
|
||||
return result
|
||||
|
||||
# stderr goes to a regular file, not a PIPE: an unread pipe can fill and
|
||||
# deadlock a chatty app-server. It is only read once the process has exited.
|
||||
# Without temporary space the probe still runs, just without its error text.
|
||||
try:
|
||||
stderr_file = tempfile.TemporaryFile("w+", errors="replace")
|
||||
except OSError:
|
||||
stderr_file = None
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
[codex, "-s", "read-only", "-a", "on-request", "app-server"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=stderr_file or subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
env=env,
|
||||
env=ENV,
|
||||
)
|
||||
except Exception as exc:
|
||||
if stderr_file:
|
||||
stderr_file.close()
|
||||
result["usageStatusText"] = "Codex unavailable"
|
||||
result["authHelpText"] = str(exc)
|
||||
return result
|
||||
|
||||
try:
|
||||
rpc_request(proc, 1, "initialize", {"clientInfo": {"name": "omarchy-agent-usage", "version": "1"}}, timeout=8)
|
||||
rpc_send(proc, {"method": "initialized", "params": {}}, "initialized")
|
||||
limits_msg = rpc_request(proc, 2, "account/rateLimits/read", timeout=8)
|
||||
proc.stdin.write(json.dumps({"method": "initialized", "params": {}}) + "\n")
|
||||
proc.stdin.flush()
|
||||
account_msg = rpc_request(proc, 2, "account/read", timeout=4)
|
||||
limits_msg = rpc_request(proc, 3, "account/rateLimits/read", timeout=4)
|
||||
|
||||
# A home nobody is signed in to answers with an error rather than limits.
|
||||
# Say so the same way the Claude collector does, so the panel offers to
|
||||
# sign that account in again.
|
||||
error = limits_msg.get("error")
|
||||
if isinstance(error, dict):
|
||||
message = str(error.get("message") or "")
|
||||
if "auth" in message.lower():
|
||||
result["usageStatusText"] = "Waiting for auth"
|
||||
result["authHelpText"] = AUTH_HELP
|
||||
else:
|
||||
result["usageStatusText"] = "Codex limits unavailable"
|
||||
result["authHelpText"] = message
|
||||
return result
|
||||
account = (account_msg.get("result") or {}).get("account") or {}
|
||||
limits = (limits_msg.get("result") or {}).get("rateLimits") or {}
|
||||
result["authHelpText"] = ""
|
||||
plan = limits.get("planType") or ""
|
||||
|
||||
# The limits name the plan themselves. account/read is only a fallback
|
||||
# for when they don't, and never a reason to lose them: Codex 0.158's
|
||||
# app-server can leave it unanswered for good.
|
||||
if not plan:
|
||||
try:
|
||||
account_msg = rpc_request(proc, 3, "account/read", timeout=2)
|
||||
account = (account_msg.get("result") or {}).get("account") or {}
|
||||
plan = account.get("planType") or account.get("type") or ""
|
||||
except (TimeoutError, RuntimeError):
|
||||
pass
|
||||
plan = limits.get("planType") or account.get("planType") or account.get("type") or ""
|
||||
result["tierLabel"] = str(plan) if plan else ""
|
||||
|
||||
for window in (limits.get("primary"), limits.get("secondary")):
|
||||
entry = limit_window(window)
|
||||
if entry:
|
||||
result["limits"].append(entry)
|
||||
|
||||
# OpenAI hands out free "full reset" credits that wipe the rate limits on
|
||||
# demand and lapse after a month. Worth knowing about before a limit bites.
|
||||
credits = reset_credits(limits_msg.get("result") or {})
|
||||
if credits:
|
||||
result["resetCredits"] = credits
|
||||
except Exception as exc:
|
||||
result["usageStatusText"] = "Codex limits unavailable"
|
||||
result["authHelpText"] = codex_rpc_help(proc, stderr_file, exc)
|
||||
result["authHelpText"] = str(exc)
|
||||
finally:
|
||||
try:
|
||||
proc.terminate()
|
||||
@@ -978,56 +568,9 @@ def fetch_codex_rpc(home=None):
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
if stderr_file:
|
||||
stderr_file.close()
|
||||
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 is exactly what it always was.
|
||||
def registered_accounts():
|
||||
state = Path(os.environ.get("XDG_STATE_HOME") or (Path.home() / ".local" / "state"))
|
||||
try:
|
||||
registry = json.loads((state / "omarchy" / "agents" / "accounts" / "codex.json").read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
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
|
||||
|
||||
|
||||
# Each account's app-server runs in that account's home, so it answers for that
|
||||
# login and refreshes that login's tokens there.
|
||||
def account_limits(account):
|
||||
# The primary is ~/.codex by definition, not whatever CODEX_HOME this run
|
||||
# happened to inherit — that would report another account's limits as Main's.
|
||||
rpc = fetch_codex_rpc(account.get("home") or (Path.home() / ".codex"))
|
||||
return {
|
||||
"id": account["id"],
|
||||
"label": str(account.get("label") or account["id"]),
|
||||
"email": str(account.get("email") or ""),
|
||||
"plan": rpc["tierLabel"] or str(account.get("plan") or ""),
|
||||
"active": account["active"],
|
||||
"primary": bool(account.get("primary")),
|
||||
"limits": rpc["limits"],
|
||||
"stale": not rpc["limits"],
|
||||
"usageStatusText": rpc["usageStatusText"],
|
||||
"authHelpText": rpc["authHelpText"],
|
||||
"resetCredits": rpc.get("resetCredits"),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
# --force rescans everything and rewrites the cache. --limits-only is kept
|
||||
@@ -1041,21 +584,7 @@ def main():
|
||||
|
||||
max_age = 0 if args.force else (LIMITS_ONLY_REUSE_SECONDS if args.limits_only else SCAN_REUSE_SECONDS)
|
||||
stats = cached_local_stats(max_age)
|
||||
registered = registered_accounts()
|
||||
accounts = [account_limits(account) for account in registered]
|
||||
current = next((a for a in accounts if a["active"]), None)
|
||||
if current:
|
||||
# The top-level fields keep describing the account new sessions use.
|
||||
rpc = {
|
||||
"limits": current["limits"],
|
||||
"tierLabel": current["plan"],
|
||||
"usageStatusText": current["usageStatusText"],
|
||||
"authHelpText": current["authHelpText"],
|
||||
}
|
||||
if current.get("resetCredits"):
|
||||
rpc["resetCredits"] = current["resetCredits"]
|
||||
else:
|
||||
rpc = fetch_codex_rpc()
|
||||
rpc = fetch_codex_rpc()
|
||||
|
||||
record = {
|
||||
"schemaVersion": 1,
|
||||
@@ -1067,9 +596,6 @@ def main():
|
||||
}
|
||||
record.update(stats)
|
||||
record.update(rpc)
|
||||
if accounts:
|
||||
record["accountSwitch"] = registered[0]["switch"]
|
||||
record["accounts"] = accounts
|
||||
print(json.dumps(record, separators=(",", ":")))
|
||||
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ 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."
|
||||
AUTH_HELP = "Set FIREWORKS_API_KEY, run `firectl set-api-key`, or sign in to Fireworks in opencode."
|
||||
API_BASE_URL = "https://api.fireworks.ai"
|
||||
|
||||
|
||||
@@ -196,57 +196,11 @@ def read_auth_file(path: Path) -> tuple[str, str]:
|
||||
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())
|
||||
@@ -273,12 +227,11 @@ def read_config() -> dict[str, Any]:
|
||||
|
||||
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.
|
||||
# opencode is the last resort: an explicit key or a firectl login should
|
||||
# win over whatever another tool happens to be signed in with.
|
||||
api_key = (
|
||||
str(os.environ.get("FIREWORKS_API_KEY", "")).strip()
|
||||
or file_key
|
||||
or read_pi_key(pi_auth_path())
|
||||
or read_opencode_key(opencode_auth_path())
|
||||
)
|
||||
account_id = (
|
||||
|
||||
@@ -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()
|
||||
@@ -51,13 +51,6 @@ collect() {
|
||||
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
|
||||
@@ -72,18 +65,4 @@ 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
|
||||
@@ -19,9 +19,6 @@ 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.
|
||||
|
||||
In an image build (/var/lib/omarchy/image/target exists) nothing runs here: every step is queued
|
||||
for omarchy-provision-hardware on the machine's first boot.
|
||||
USAGE
|
||||
}
|
||||
|
||||
@@ -69,21 +66,10 @@ else
|
||||
fi
|
||||
|
||||
export OMARCHY_INSTALL_USER="$install_user"
|
||||
# Only omarchy-provision-hardware runs steps as an image's first boot.
|
||||
unset OMARCHY_IMAGE_DEFERRED_HARDWARE
|
||||
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/helpers/image-target.sh"
|
||||
|
||||
omarchy_image_init
|
||||
omarchy_image_lock
|
||||
if omarchy_image_manifest_present; then
|
||||
omarchy_image_read_manifest
|
||||
omarchy_image_defer_hardware
|
||||
else
|
||||
source "$OMARCHY_INSTALL/hardware/all.sh"
|
||||
fi
|
||||
source "$OMARCHY_INSTALL/hardware/all.sh"
|
||||
+2
-34
@@ -6,12 +6,6 @@
|
||||
|
||||
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)
|
||||
@@ -40,41 +34,15 @@ 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
|
||||
if omarchy-cmd-present fprintd-list && fprintd-list "$target_user" 2>/dev/null | grep -qi finger; then
|
||||
echo "Configuring lock screen fingerprint authentication..."
|
||||
as_root 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
|
||||
as_root rm -f /etc/pam.d/omarchy-lock-fingerprint
|
||||
fi
|
||||
|
||||
# omarchy-shell can't reach a running shell during chroot install. The echo
|
||||
|
||||
@@ -6,8 +6,8 @@ wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle >/dev/null
|
||||
|
||||
if wpctl get-volume @DEFAULT_AUDIO_SOURCE@ | grep -q MUTED; then
|
||||
omarchy-brightness-keyboard-mute on
|
||||
omarchy-osd -i microphone-muted -m "$(omarchy-text "Microphone muted")"
|
||||
omarchy-osd -i microphone-muted -m "Microphone muted"
|
||||
else
|
||||
omarchy-brightness-keyboard-mute off
|
||||
omarchy-osd -i microphone -m "$(omarchy-text "Microphone on")"
|
||||
omarchy-osd -i microphone -m "Microphone on"
|
||||
fi
|
||||
@@ -15,14 +15,6 @@ fi
|
||||
wpctl set-default "$node_id" 2>/dev/null || true
|
||||
pactl set-default-source "$source_name" 2>/dev/null || true
|
||||
|
||||
# A filter's own capture (the input half of a filter chain, loopback or echo
|
||||
# canceller, which PipeWire joins to its output by node.link-group) is the
|
||||
# processing, not an app: moving it would feed the filter from the new input, or
|
||||
# from its own output. pactl translates the listing, so it is read in the C
|
||||
# locale.
|
||||
LC_ALL=C pactl list source-outputs 2>/dev/null | awk '
|
||||
/^Source Output #/ { if (id != "" && !filter) print id; id = substr($3, 2); filter = 0 }
|
||||
/^[[:space:]]*node\.link-group = / { filter = 1 }
|
||||
END { if (id != "" && !filter) print id }' | while read -r output; do
|
||||
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
|
||||
@@ -29,25 +29,16 @@ fi
|
||||
# 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
|
||||
if (index(name, virt) == 1 && target != "") {print target; exit}
|
||||
}
|
||||
/application\.name = "EasyEffects"/ {
|
||||
if (virt == "easyeffects_sink" && target != "") {selected = target; exit}
|
||||
}
|
||||
END {
|
||||
if (selected != "") print selected
|
||||
else if (fallback != "") print fallback
|
||||
if (virt == "easyeffects_sink" && target != "") {print target; exit}
|
||||
}')"
|
||||
|
||||
if [[ -n $downstream ]]; then
|
||||
@@ -59,38 +50,6 @@ if [[ -n $downstream ]]; then
|
||||
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"
|
||||
@@ -13,7 +13,7 @@ sinks=$(timeout 2 pactl -f json list sinks |
|
||||
sinks_count=$(jq 'length' <<<"$sinks")
|
||||
|
||||
if (( sinks_count == 0 )); then
|
||||
omarchy-osd -m "$(omarchy-text "No audio devices found")"
|
||||
omarchy-osd -m "No audio devices found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,27 +1,14 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Print PulseAudio sink or source availability for the shell
|
||||
# omarchy:summary=Print PulseAudio sink availability for the shell
|
||||
# omarchy:group=audio
|
||||
# omarchy:args=[sources]
|
||||
|
||||
# With "sources", print source availability instead: a jack input such as a
|
||||
# headset microphone reports its port unavailable while nothing is plugged in.
|
||||
# pactl translates the listing ("not available" too), so it is read in the C
|
||||
# locale.
|
||||
kind=sinks
|
||||
header="Sink #"
|
||||
if [[ ${1:-} == "sources" ]]; then
|
||||
kind=sources
|
||||
header="Source #"
|
||||
fi
|
||||
|
||||
# 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=""
|
||||
[[ $kind == sinks ]] && fronted="$(omarchy-audio-tuning fronted-sink 2>/dev/null || true)"
|
||||
fronted="$(omarchy-audio-tuning fronted-sink 2>/dev/null || true)"
|
||||
|
||||
LC_ALL=C pactl list "$kind" 2>/dev/null | awk -v fronted="$fronted" -v header="$header" '
|
||||
pactl list sinks 2>/dev/null | awk -v fronted="$fronted" '
|
||||
function emit_sink() {
|
||||
if (name == "") return
|
||||
if (fronted != "" && name == fronted) {
|
||||
@@ -31,7 +18,7 @@ LC_ALL=C pactl list "$kind" 2>/dev/null | awk -v fronted="$fronted" -v header="$
|
||||
print name "\t" ((port_count == 0 || available) ? 1 : 0)
|
||||
}
|
||||
|
||||
index($0, header) == 1 {
|
||||
/^Sink #/ {
|
||||
emit_sink()
|
||||
name = ""
|
||||
in_ports = 0
|
||||
|
||||
+9
-103
@@ -2,7 +2,7 @@
|
||||
|
||||
# omarchy:summary=Configure the bar and its widget layout
|
||||
# omarchy:group=bar
|
||||
# omarchy:args=use <id> | reset | defaults | position [top|bottom|left|right] | background [opaque|glassy|none|cycle] | put <id> [placement] | move <id> [placement] | set <id> <key> <value> [--json] [placement] | get <id> <key>
|
||||
# omarchy:args=use <id> | reset | defaults | position <top|bottom|left|right> | transparent <true|false|toggle> | put <id> [placement] | move <id> [placement] | set <id> <key> <value> [--json] [placement]
|
||||
# omarchy:examples=omarchy bar use local.neon-bar | omarchy bar put omarchy.keyboard-layout --after omarchy.clock | omarchy bar move omarchy.clock --section center --index 0 | omarchy bar set omarchy.clock format HH:mm
|
||||
|
||||
set -euo pipefail
|
||||
@@ -16,19 +16,13 @@ Usage: omarchy bar <command> [args...]
|
||||
use <id> Use a bar option as the active bar
|
||||
reset Return to the built-in Omarchy bar
|
||||
defaults Restore the default bar and service widgets
|
||||
position [top|bottom|left|right] Bar position; prints the current one
|
||||
when given none
|
||||
background [opaque|glassy|none|cycle]
|
||||
Bar background: glassy shows what is
|
||||
behind the bar blurred, cycle steps
|
||||
none, glassy, opaque; prints the
|
||||
current one when given none
|
||||
position <top|bottom|left|right> Bar position
|
||||
transparent <true|false|toggle> Bar transparency
|
||||
put <id> [placement] Put a widget on the bar, leaving one
|
||||
that is already there where it is
|
||||
move <id> [placement] Move a widget within or between sections
|
||||
set <id> <key> <value> [--json] [placement]
|
||||
Set a per-widget option
|
||||
get <id> <key> Print a per-widget option
|
||||
|
||||
Placement:
|
||||
--section <left|center|right> Target section
|
||||
@@ -198,94 +192,32 @@ cmd_defaults() {
|
||||
" \
|
||||
--slurpfile defaults "$DEFAULTS_FILE" \
|
||||
--argjson widgets "$optional_widgets"
|
||||
sync_glass || fail "could not change the blur behind the bar"
|
||||
echo "Restored the default Omarchy bar"
|
||||
}
|
||||
|
||||
cmd_position() {
|
||||
if (( $# == 0 )); then
|
||||
jq -r '.bar.position // "top"' "$(source_file)"
|
||||
return
|
||||
fi
|
||||
|
||||
local position="$1"
|
||||
local position="${1:-}"
|
||||
[[ -n $position ]] || fail "position is required"
|
||||
(( $# == 1 )) || fail "position takes a single value"
|
||||
[[ $position =~ ^(top|bottom|left|right)$ ]] || fail "position must be top, bottom, left, or right"
|
||||
commit "$NORMALIZE | .bar.position = \$position" --arg position "$position"
|
||||
echo "Bar position set to $position"
|
||||
}
|
||||
|
||||
# The bar background is opaque, glassy, or none. A shell.json from before it
|
||||
# had one says transparent instead, which is none, and setting a background
|
||||
# drops it.
|
||||
current_background() {
|
||||
jq -r '.bar.background // (if .bar.transparent == true then "none" else "opaque" end)' "$(source_file)"
|
||||
}
|
||||
|
||||
cmd_background() {
|
||||
if (( $# == 0 )); then
|
||||
current_background
|
||||
return
|
||||
fi
|
||||
|
||||
local background="$1"
|
||||
(( $# == 1 )) || fail "background takes a single value"
|
||||
[[ $background =~ ^(opaque|glassy|none|cycle)$ ]] || fail "background must be opaque, glassy, none, or cycle"
|
||||
|
||||
if [[ $background == "cycle" ]]; then
|
||||
# Each step changes the bar by one degree: clear, then glassy, then opaque.
|
||||
case $(current_background) in
|
||||
none) background="glassy" ;;
|
||||
glassy) background="opaque" ;;
|
||||
*) background="none" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
local previous
|
||||
previous=$(current_background)
|
||||
commit "$NORMALIZE | .bar.background = \$background | del(.bar.transparent)" --arg background "$background"
|
||||
if ! sync_glass; then
|
||||
commit "$NORMALIZE | .bar.background = \$background | del(.bar.transparent)" --arg background "$previous"
|
||||
fail "could not change the blur behind the bar"
|
||||
fi
|
||||
echo "Bar background set to $background"
|
||||
}
|
||||
|
||||
# The older on/off form of the background, kept for scripts and dotfiles:
|
||||
# transparent is none, and not transparent is opaque.
|
||||
cmd_transparent() {
|
||||
local transparent="${1:-}"
|
||||
[[ -n $transparent ]] || fail "transparent is required"
|
||||
(( $# == 1 )) || fail "transparent takes a single value"
|
||||
[[ $transparent =~ ^(true|false|toggle)$ ]] || fail "transparent must be true, false, or toggle"
|
||||
if [[ $transparent == "toggle" ]]; then
|
||||
[[ $(current_background) == "none" ]] && transparent="false" || transparent="true"
|
||||
fi
|
||||
if [[ $transparent == "true" ]]; then
|
||||
cmd_background none
|
||||
commit "$NORMALIZE | .bar.transparent = (.bar.transparent != true)"
|
||||
echo "Bar transparency toggled"
|
||||
else
|
||||
cmd_background opaque
|
||||
commit "$NORMALIZE | .bar.transparent = \$transparent" --argjson transparent "$transparent"
|
||||
echo "Bar transparency set to $transparent"
|
||||
fi
|
||||
}
|
||||
|
||||
# Hyprland blurs behind the bar only while the bar-glassy toggle is on, so it
|
||||
# follows the bar background, reloading Hyprland only when it changes. The
|
||||
# toggle sits where Hyprland reads it, under XDG_STATE_HOME.
|
||||
sync_glass() {
|
||||
local dir="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/toggles/hypr"
|
||||
local flag="$dir/bar-glassy.lua"
|
||||
|
||||
if [[ $(current_background) == "glassy" ]]; then
|
||||
[[ ! -f $flag ]] || return 0
|
||||
mkdir -p "$dir" && cp "$OMARCHY_PATH/default/hypr/toggles/bar-glassy.lua" "$flag" || return 1
|
||||
else
|
||||
[[ -f $flag ]] || return 0
|
||||
rm -f "$flag" || return 1
|
||||
fi
|
||||
|
||||
hyprctl reload >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
bar_widget_default_section() {
|
||||
local catalog
|
||||
catalog=$(omarchy-plugin-catalog 2>/dev/null) || {
|
||||
@@ -431,21 +363,6 @@ cmd_set() {
|
||||
echo "Set $key on $id"
|
||||
}
|
||||
|
||||
# Prints the option from the first instance of the widget on the bar, raw for a
|
||||
# string and as JSON otherwise, and fails when no instance sets it.
|
||||
cmd_get() {
|
||||
local id="${1:-}"
|
||||
local key="${2:-}"
|
||||
[[ -n $id ]] || fail "get requires a widget id"
|
||||
[[ -n $key ]] || fail "get requires a setting key"
|
||||
(( $# == 2 )) || fail "get takes a widget id and a setting key"
|
||||
|
||||
jq -e -r --arg id "$id" --arg key "$key" '
|
||||
first(.bar.layout[]?[]? | select(.id == $id) | .[$key] | select(. != null))
|
||||
| if type == "string" then . else tojson end
|
||||
' "$(source_file)" 2>/dev/null || exit 1
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------- dispatch
|
||||
|
||||
command="${1:-}"
|
||||
@@ -468,14 +385,6 @@ case "$command" in
|
||||
transparent)
|
||||
cmd_transparent "$@"
|
||||
;;
|
||||
background)
|
||||
cmd_background "$@"
|
||||
;;
|
||||
# Internal: the shell runs this when it loads a bar background, so one set
|
||||
# by editing shell.json gets its blur too.
|
||||
sync-glass)
|
||||
sync_glass || fail "could not change the blur behind the bar"
|
||||
;;
|
||||
put)
|
||||
cmd_put "$@"
|
||||
;;
|
||||
@@ -485,9 +394,6 @@ case "$command" in
|
||||
set)
|
||||
cmd_set "$@"
|
||||
;;
|
||||
get)
|
||||
cmd_get "$@"
|
||||
;;
|
||||
-h | --help | help | "")
|
||||
usage
|
||||
;;
|
||||
|
||||
@@ -109,10 +109,7 @@ right)
|
||||
;;
|
||||
esac
|
||||
|
||||
# Sample the first frame only. Without the selector a video background makes
|
||||
# ImageMagick decode the whole file and emit one value per frame, and the match
|
||||
# below then fails into the fallback colour.
|
||||
pixel=$(magick "$background_path[0]" -auto-orient \
|
||||
pixel=$(magick "$background_path" -auto-orient \
|
||||
-resize "${screen_width}x${screen_height}^" \
|
||||
-gravity center -extent "${screen_width}x${screen_height}" \
|
||||
-gravity NorthWest -crop "$crop" +repage \
|
||||
|
||||
@@ -13,5 +13,5 @@ fi
|
||||
|
||||
level=$1
|
||||
|
||||
omarchy-notification-send -g -u critical "$(omarchy-text "Time to recharge!")" "$(omarchy-text "Battery is down to %1%" "$level")" -i battery-caution -t 30000
|
||||
omarchy-notification-send -g -u critical "Time to recharge!" "Battery is down to ${level}%" -i battery-caution -t 30000
|
||||
omarchy-hook battery-low "$level"
|
||||
@@ -2,16 +2,10 @@
|
||||
|
||||
# omarchy:summary=Returns true if a battery is present on the system.
|
||||
|
||||
# Not every battery is called BAT*: Apple Silicon reports macsmc-battery and
|
||||
# some Framework and Chromebook firmware CMB0, so decide by type, not by name.
|
||||
power_supply_path="${OMARCHY_POWER_SUPPLY_PATH:-/sys/class/power_supply}"
|
||||
|
||||
for bat in "$power_supply_path"/*; do
|
||||
[[ -r $bat/present && $(<"$bat/present") == "1" ]] || continue
|
||||
[[ -r $bat/type && $(<"$bat/type") == "Battery" ]] || continue
|
||||
# A wireless mouse or keyboard registers a Battery too, scoped Device. The
|
||||
# one that powers the machine is scoped System, or unscoped on older drivers.
|
||||
[[ -r $bat/scope && $(<"$bat/scope") == "Device" ]] && continue
|
||||
for bat in /sys/class/power_supply/BAT*; do
|
||||
[[ -r $bat/present ]] &&
|
||||
[[ $(cat $bat/present) == "1" ]] &&
|
||||
[[ $(cat $bat/type) == "Battery" ]] &&
|
||||
exit 0
|
||||
done
|
||||
|
||||
|
||||
@@ -18,23 +18,11 @@ case "${1:-}" in
|
||||
;;
|
||||
esac
|
||||
|
||||
# UPower names the device after the sysfs entry, and only some kernels call
|
||||
# that BAT*: Apple Silicon reports battery_macsmc_battery. Match on the class,
|
||||
# then take the first one that powers the machine: a wireless mouse or keyboard
|
||||
# is a battery_ device too, and UPower marks it "power supply: no".
|
||||
battery=""
|
||||
battery_info=""
|
||||
while read -r candidate; do
|
||||
[[ -n $candidate ]] || continue
|
||||
candidate_info=$(upower -i "$candidate" 2>/dev/null) || continue
|
||||
if grep -qE '^\s*power supply:\s+yes' <<<"$candidate_info"; then
|
||||
battery=$candidate
|
||||
battery_info=$candidate_info
|
||||
break
|
||||
fi
|
||||
done < <(upower -e 2>/dev/null | grep '/battery_')
|
||||
battery=$(upower -e 2>/dev/null | grep BAT | head -n 1)
|
||||
[[ -z $battery ]] && exit 0
|
||||
|
||||
battery_info=$(upower -i "$battery")
|
||||
|
||||
percentage=$(awk '/percentage/ { print int($2); exit }' <<<"$battery_info")
|
||||
capacity=$(awk '/energy-full:/ { printf "%d", $2; exit }' <<<"$battery_info")
|
||||
time_remaining=$(awk '/time to (empty|full)/ {
|
||||
@@ -68,10 +56,7 @@ elif [[ -r $battery_path/current_now && -r $battery_path/voltage_now ]]; then
|
||||
'BEGIN { print microamps * microvolts / 1000000000000 }')
|
||||
fi
|
||||
|
||||
# Some kernels sign power_now by direction (negative while discharging on
|
||||
# Apple Silicon); the state field already says which way it flows.
|
||||
power_rate=$(awk -v rate="${power_rate_raw:-0}" 'BEGIN {
|
||||
if (rate < 0) rate = -rate
|
||||
rounded = sprintf("%.1f", rate)
|
||||
sub(/\.0$/, "", rounded)
|
||||
print rounded
|
||||
@@ -80,12 +65,8 @@ state=$(awk '/state/ { print $2; exit }' <<<"$battery_info")
|
||||
threshold_start=$(awk '/charge-start-threshold:/ { gsub(/%/, "", $2); print int($2); exit }' <<<"$battery_info")
|
||||
threshold_end=$(awk '/charge-end-threshold:/ { gsub(/%/, "", $2); print int($2); exit }' <<<"$battery_info")
|
||||
|
||||
# Read the thresholds from the battery UPower resolved rather than a BAT* glob,
|
||||
# which misses every battery with another name.
|
||||
[[ -n $threshold_end || ! -r $battery_path/charge_control_end_threshold ]] || threshold_end=$(<"$battery_path/charge_control_end_threshold")
|
||||
[[ -n $threshold_start || ! -r $battery_path/charge_control_start_threshold ]] || threshold_start=$(<"$battery_path/charge_control_start_threshold")
|
||||
[[ -n $threshold_end ]] || threshold_end=$(cat "$power_supply_path"/BAT*/charge_control_end_threshold 2>/dev/null | head -1)
|
||||
[[ -n $threshold_start ]] || threshold_start=$(cat "$power_supply_path"/BAT*/charge_control_start_threshold 2>/dev/null | head -1)
|
||||
[[ -z $threshold_end ]] && threshold_end=$(cat "$power_supply_path"/BAT*/charge_control_end_threshold 2>/dev/null | head -1)
|
||||
[[ -z $threshold_start ]] && threshold_start=$(cat "$power_supply_path"/BAT*/charge_control_start_threshold 2>/dev/null | head -1)
|
||||
|
||||
ac_online=false
|
||||
for supply in "$power_supply_path"/*; do
|
||||
@@ -126,9 +107,7 @@ if [[ $shell_output == "true" ]]; then
|
||||
printf 'size\t%s\n' "${capacity}Wh"
|
||||
printf 'time\t%s\n' "$time_remaining"
|
||||
|
||||
cycles=""
|
||||
[[ ! -r $battery_path/cycle_count ]] || cycles=$(<"$battery_path/cycle_count")
|
||||
[[ -n $cycles ]] || cycles=$(cat "$power_supply_path"/BAT*/cycle_count 2>/dev/null | head -1)
|
||||
cycles=$(cat "$power_supply_path"/BAT*/cycle_count 2>/dev/null | head -1)
|
||||
|
||||
[[ -n $cycles ]] && printf 'cycles\t%s\n' "$cycles"
|
||||
|
||||
@@ -150,9 +129,9 @@ if [[ $charge_holding == "true" ]]; then
|
||||
threshold_label="${threshold_end}%"
|
||||
fi
|
||||
|
||||
omarchy-text "Battery %1% · Holding at %2 · %3W / %4Wh" "$percentage" "$threshold_label" "$power_rate" "$capacity"
|
||||
echo "Battery ${percentage}% · Holding at ${threshold_label} · ${power_rate}W / ${capacity}Wh"
|
||||
elif [[ $state == "charging" ]]; then
|
||||
omarchy-text "Battery %1% · %2 to full · %3W / %4Wh" "$percentage" "$time_remaining" "$power_rate" "$capacity"
|
||||
echo "Battery ${percentage}% · ${time_remaining} to full · ${power_rate}W / ${capacity}Wh"
|
||||
else
|
||||
omarchy-text "Battery %1% · %2 left · %3W / %4Wh" "$percentage" "$time_remaining" "$power_rate" "$capacity"
|
||||
echo "Battery ${percentage}% · ${time_remaining} left · ${power_rate}W / ${capacity}Wh"
|
||||
fi
|
||||
@@ -40,30 +40,7 @@ use_apple_display() {
|
||||
}
|
||||
|
||||
use_ddc_display() {
|
||||
[[ -n $monitor ]] && ! monitor_is_internal && monitor_may_have_ddc
|
||||
}
|
||||
|
||||
# A platform whose display driver registers no DDC channel says so in its
|
||||
# displays.conf (see docs/file-layout.md): there a probe finds only unrelated
|
||||
# I2C buses, slowly, so only a connector with a ddc node is probed. Everywhere
|
||||
# else every external monitor is.
|
||||
monitor_may_have_ddc() {
|
||||
local displays_conf=/usr/share/omarchy-platform/displays.conf directive argument
|
||||
[[ -r $displays_conf ]] || return 0
|
||||
|
||||
while IFS=$' \t\r' read -r directive argument || [[ -n $directive ]]; do
|
||||
if [[ $directive == "ddc-require-connector-ddc" && -z $argument ]]; then
|
||||
compgen -G "/sys/class/drm/card*-$monitor/ddc" >/dev/null
|
||||
return
|
||||
fi
|
||||
done <"$displays_conf"
|
||||
return 0
|
||||
}
|
||||
|
||||
# The kernel backlight is the built-in panel's, so an external monitor without
|
||||
# DDC fails rather than dimming the panel.
|
||||
use_backlight_display() {
|
||||
[[ -z $monitor ]] || monitor_is_internal
|
||||
[[ -n $monitor ]] && ! monitor_is_internal
|
||||
}
|
||||
|
||||
if (( $# == 0 )); then
|
||||
@@ -73,8 +50,6 @@ if (( $# == 0 )); then
|
||||
elif use_ddc_display; then
|
||||
omarchy-brightness-display-ddc "$monitor"
|
||||
exit
|
||||
elif ! use_backlight_display; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
device="$(omarchy-hw-display)" || exit 1
|
||||
@@ -110,8 +85,6 @@ if use_apple_display; then
|
||||
elif use_ddc_display; then
|
||||
brightness="$(omarchy-brightness-display-ddc "$monitor" "$step")" || exit 1
|
||||
(( no_osd )) || omarchy-osd -i brightness -p "$brightness"
|
||||
elif ! use_backlight_display; then
|
||||
exit 1
|
||||
else
|
||||
# Current device highlighted
|
||||
device="$(omarchy-hw-display)" || exit 1
|
||||
|
||||
@@ -23,7 +23,7 @@ SELECTION=$(slurp 2>/dev/null)
|
||||
RESULT=$(grim -g "$SELECTION" - | zbarimg -q --raw -Sdisable -Sqrcode.enable - 2>/dev/null)
|
||||
|
||||
if [[ -z $RESULT ]]; then
|
||||
omarchy-notification-send -g -u critical "$(omarchy-text "No QR code found")" "$(omarchy-text "Select a region containing a QR code")"
|
||||
omarchy-notification-send -g -u critical "No QR code found" "Select a region containing a QR code"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -33,4 +33,4 @@ fi
|
||||
# the notification history, and an unmarked copy would be retained by clipboard
|
||||
# history. Pasting still works; only the recorded copy is given up.
|
||||
printf '%s' "$RESULT" | wl-copy --sensitive
|
||||
omarchy-notification-send -g "$(omarchy-text "QR code copied to clipboard")"
|
||||
omarchy-notification-send -g "QR code copied to clipboard"
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# omarchy:summary=Start or stop screen recording
|
||||
# omarchy:group=capture
|
||||
# omarchy:args=[--fullscreen] [--with-desktop-audio] [--with-microphone-audio] [--with-webcam] [--webcam-device=<device>] [--webcam-size=<small|medium|large>] [--resolution=<size>] [--stop-recording] [--status]
|
||||
# omarchy:args=[--fullscreen] [--with-desktop-audio] [--with-microphone-audio] [--with-webcam] [--webcam-device=<device>] [--webcam-size=<small|medium|large>] [--resolution=<size>] [--stop-recording]
|
||||
# omarchy:examples=omarchy screenrecord | omarchy capture screenrecording --with-desktop-audio
|
||||
# omarchy:aliases=omarchy screenrecord
|
||||
#
|
||||
@@ -14,20 +14,18 @@
|
||||
# DMA-BUF modifier import on some configurations, leaving recording unable to
|
||||
# start.
|
||||
#
|
||||
# Env: OMARCHY_SCREENRECORD_DEBUG=true appends the recorder's stderr (and the
|
||||
# picker target it was launched with) to $XDG_RUNTIME_DIR/omarchy-screenrecord.log
|
||||
# so users can attach a log when reporting capture failures.
|
||||
#
|
||||
# Backend: gpu-screen-recorder. Where it is not installed, or it exits before
|
||||
# the recording starts (it knows only NVIDIA, AMD, Intel and Broadcom GPUs, and
|
||||
# aborts on others such as Apple Silicon's), wf-recorder records instead when
|
||||
# it is installed. wf-recorder encodes on the CPU and captures one audio
|
||||
# device, so desktop and microphone audio together go through a temporary null
|
||||
# sink. Portal capture and --resolution are gpu-screen-recorder only.
|
||||
# Env: OMARCHY_SCREENRECORD_DEBUG=true appends gpu-screen-recorder's stderr (and
|
||||
# the picker target it was launched with) to /tmp/omarchy-screenrecord.log so
|
||||
# users can attach a log when reporting capture failures.
|
||||
|
||||
[[ -f ~/.config/user-dirs.dirs ]] && source ~/.config/user-dirs.dirs
|
||||
OUTPUT_DIR="${OMARCHY_SCREENRECORD_DIR:-${XDG_VIDEOS_DIR:-$HOME/Videos}}"
|
||||
|
||||
if [[ ! -d $OUTPUT_DIR ]]; then
|
||||
omarchy-notification-send -u critical -t 3000 "Screen recording directory does not exist: $OUTPUT_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DESKTOP_AUDIO="false"
|
||||
MICROPHONE_AUDIO="false"
|
||||
WEBCAM="false"
|
||||
@@ -36,30 +34,9 @@ WEBCAM_SIZE="medium"
|
||||
RESOLUTION=""
|
||||
FULLSCREEN="false"
|
||||
STOP_RECORDING="false"
|
||||
STATUS="false"
|
||||
# Both of these live in the per-user runtime directory, which is 0700. What
|
||||
# RECORDING_FILE holds is read back on stop and used as a path -- ffmpeg writes
|
||||
# beside it, `mv` replaces it, and `rm -f` deletes its preview -- so a name in
|
||||
# world-writable /tmp is a name any other local account can create first and
|
||||
# then point wherever it likes. The debug log carries the recorder's stderr and
|
||||
# the picked geometry, which is nobody else's business either. Fall back to
|
||||
# the state directory when there is no session runtime dir -- :-/tmp would put
|
||||
# both files back at the fixed names this comment describes.
|
||||
RUNTIME_DIR="${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/omarchy}"
|
||||
RECORDING_FILE="$RUNTIME_DIR/omarchy-screenrecord-filename"
|
||||
REGION_FILE="$RUNTIME_DIR/omarchy-screenrecord-region"
|
||||
# The recorder's pid, so stop and status act on that process alone.
|
||||
REC_PID_FILE="$RUNTIME_DIR/omarchy-screenrecord-pid"
|
||||
# The PulseAudio module ids of a combined-audio null sink, for cleanup.
|
||||
PA_MODULES_FILE="$RUNTIME_DIR/omarchy-screenrecord-pa-modules"
|
||||
# Present while a start holds the lock; another toggle or stop then leaves
|
||||
# CANCEL_FILE for that start instead of queueing behind its picker or recorder.
|
||||
STARTING_FILE="$RUNTIME_DIR/omarchy-screenrecord-starting"
|
||||
CANCEL_FILE="$RUNTIME_DIR/omarchy-screenrecord-cancel"
|
||||
# How long a recorder may take to create its file, except through the portal,
|
||||
# whose picker waits on the user.
|
||||
READY_TIMEOUT_TICKS=75
|
||||
LOG_FILE=$([[ ${OMARCHY_SCREENRECORD_DEBUG:-false} == "true" ]] && echo "$RUNTIME_DIR/omarchy-screenrecord.log" || echo "/dev/null")
|
||||
RECORDING_FILE="/tmp/omarchy-screenrecord-filename"
|
||||
REGION_FILE="${XDG_RUNTIME_DIR:-/tmp}/omarchy-screenrecord-region"
|
||||
LOG_FILE=$([[ ${OMARCHY_SCREENRECORD_DEBUG:-false} == "true" ]] && echo "/tmp/omarchy-screenrecord.log" || echo "/dev/null")
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
@@ -71,7 +48,6 @@ for arg in "$@"; do
|
||||
--resolution=*) RESOLUTION="${arg#*=}" ;;
|
||||
--fullscreen) FULLSCREEN="true" ;;
|
||||
--stop-recording) STOP_RECORDING="true" ;;
|
||||
--status) STATUS="true" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -83,13 +59,6 @@ small | medium | large) ;;
|
||||
;;
|
||||
esac
|
||||
|
||||
# --status only answers whether our recording runs, for the bar and the menu, so
|
||||
# it needs no recordings directory and never notifies.
|
||||
if [[ $STATUS != "true" && ! -d $OUTPUT_DIR ]]; then
|
||||
omarchy-notification-send -u critical -t 3000 "$(omarchy-text "Screen recording directory does not exist: %1" "$OUTPUT_DIR")"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
start_webcam_overlay() {
|
||||
cleanup_webcam
|
||||
|
||||
@@ -97,7 +66,7 @@ start_webcam_overlay() {
|
||||
if [[ -z $WEBCAM_DEVICE ]]; then
|
||||
WEBCAM_DEVICE=$(omarchy-capture-webcam-list | sed -n '1s/[[:space:]].*//p')
|
||||
if [[ -z $WEBCAM_DEVICE ]]; then
|
||||
omarchy-notification-send -u critical -t 3000 "$(omarchy-text "No webcam devices found")"
|
||||
omarchy-notification-send -u critical -t 3000 "No webcam devices found"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
@@ -120,7 +89,7 @@ start_webcam_overlay() {
|
||||
'--vf=lavfi=[crop=ih*8/9:ih]' \
|
||||
--title="WebcamOverlay" --wayland-app-id="WebcamOverlay-$WEBCAM_SIZE" \
|
||||
--no-border --no-audio --no-osc --osd-level=0 \
|
||||
--really-quiet 9>&- &>/dev/null &
|
||||
--really-quiet &>/dev/null &
|
||||
|
||||
# The move has to settle before gpu-screen-recorder starts, or the camera is
|
||||
# recorded sliding into its corner. Waiting for the map is what the blind
|
||||
@@ -174,134 +143,7 @@ select_capture_target() {
|
||||
echo "region:${BASH_REMATCH[3]}x${BASH_REMATCH[4]}+${BASH_REMATCH[1]}+${BASH_REMATCH[2]}"
|
||||
}
|
||||
|
||||
# gpu-screen-recorder first, then wf-recorder, of those installed. The base
|
||||
# packages carry gpu-screen-recorder, but an architecture may build none.
|
||||
recorder_backends() {
|
||||
local backend found=false
|
||||
|
||||
for backend in gpu-screen-recorder wf-recorder; do
|
||||
if omarchy-cmd-present "$backend"; then
|
||||
echo "$backend"
|
||||
found=true
|
||||
fi
|
||||
done
|
||||
|
||||
# Neither installed: fail the way a missing gpu-screen-recorder always has.
|
||||
$found || echo gpu-screen-recorder
|
||||
}
|
||||
|
||||
# wf-recorder records a single audio device. Map the desktop/mic flags onto one:
|
||||
# desktop -> default sink monitor, mic -> default source. When both are requested,
|
||||
# route them through a throwaway null sink so a single track carries both; the
|
||||
# module ids are saved to PA_MODULES_FILE so stop can unload them. A requested
|
||||
# source that can't be recorded is left out with a notification naming it, and
|
||||
# the recording carries whichever source remains.
|
||||
wf_audio_device() {
|
||||
local sink="" source="" m
|
||||
[[ $DESKTOP_AUDIO == "true" ]] && sink=$(pactl get-default-sink 2>/dev/null)
|
||||
[[ $MICROPHONE_AUDIO == "true" ]] && source=$(pactl get-default-source 2>/dev/null)
|
||||
|
||||
if [[ -n $sink && -n $source ]]; then
|
||||
if m=$(pactl load-module module-null-sink sink_name=omarchy_screenrec sink_properties=device.description=omarchy_screenrec 2>/dev/null); then
|
||||
echo "$m" >"$PA_MODULES_FILE"
|
||||
if m=$(pactl load-module module-loopback source="${sink}.monitor" sink=omarchy_screenrec latency_msec=20 2>/dev/null); then
|
||||
echo "$m" >>"$PA_MODULES_FILE"
|
||||
else
|
||||
sink=""
|
||||
fi
|
||||
if m=$(pactl load-module module-loopback source="$source" sink=omarchy_screenrec latency_msec=20 2>/dev/null); then
|
||||
echo "$m" >>"$PA_MODULES_FILE"
|
||||
else
|
||||
source=""
|
||||
fi
|
||||
if [[ -n $sink && -n $source ]]; then
|
||||
echo "omarchy_screenrec.monitor"
|
||||
return
|
||||
fi
|
||||
wf_audio_cleanup
|
||||
else
|
||||
# No device to mix into: desktop audio records alone, as it always has.
|
||||
source=""
|
||||
fi
|
||||
fi
|
||||
|
||||
[[ $DESKTOP_AUDIO == "true" && -z $sink ]] && wf_audio_unavailable desktop
|
||||
[[ $MICROPHONE_AUDIO == "true" && -z $source ]] && wf_audio_unavailable microphone
|
||||
if [[ -n $sink ]]; then
|
||||
echo "${sink}.monitor"
|
||||
elif [[ -n $source ]]; then
|
||||
echo "$source"
|
||||
fi
|
||||
}
|
||||
|
||||
wf_audio_unavailable() {
|
||||
local audio
|
||||
audio=$(omarchy-text "$1")
|
||||
omarchy-notification-send -u critical -t 5000 "$(omarchy-text "Recording without %1 audio" "$audio")" "$(omarchy-text "The %1 audio could not be added to this recording." "$audio")" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
wf_audio_cleanup() {
|
||||
[[ -f $PA_MODULES_FILE ]] || return
|
||||
# Unload in reverse so the loopbacks drop before the null sink they feed.
|
||||
tac "$PA_MODULES_FILE" 2>/dev/null | while read -r m; do
|
||||
[[ -n $m ]] && pactl unload-module "$m" 2>/dev/null
|
||||
done
|
||||
rm -f "$PA_MODULES_FILE"
|
||||
}
|
||||
|
||||
# Launches wf-recorder for the given picker target, backgrounded, with REC_PID set.
|
||||
launch_wf_recorder() {
|
||||
local target=$1 filename=$2
|
||||
local geom_args=()
|
||||
|
||||
case $target in
|
||||
monitor:*)
|
||||
geom_args=(-o "${target#monitor:}")
|
||||
;;
|
||||
region:*)
|
||||
# region:WxH+X+Y (logical coords) -> wf-recorder -g "X,Y WxH" (same space).
|
||||
[[ ${target#region:} =~ ^([0-9]+)x([0-9]+)\+(-?[0-9]+)\+(-?[0-9]+)$ ]] || return 1
|
||||
geom_args=(-g "${BASH_REMATCH[3]},${BASH_REMATCH[4]} ${BASH_REMATCH[1]}x${BASH_REMATCH[2]}")
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
local audio_args=() dev
|
||||
dev=$(wf_audio_device)
|
||||
[[ -n $dev ]] && audio_args=(--audio="$dev")
|
||||
|
||||
# -r 60 caps the framerate: capturing a 120 Hz panel at its native rate makes
|
||||
# needlessly large files. -R 48000 keeps the AAC track at 48 kHz rather than
|
||||
# following a sink that advertises 96 kHz into a file many players, and some
|
||||
# speaker DSPs, play as silence.
|
||||
wf-recorder "${geom_args[@]}" -r 60 -R 48000 "${audio_args[@]}" -f "$filename" 9>&- 2>>"$LOG_FILE" &
|
||||
REC_PID=$!
|
||||
}
|
||||
|
||||
start_cancelled() {
|
||||
[[ -e $CANCEL_FILE ]]
|
||||
}
|
||||
|
||||
# Stops a recorder this start launched: TERM, two seconds to exit, then KILL.
|
||||
end_launched_recorder() {
|
||||
local pid=$1 waited=0 state
|
||||
kill "$pid" 2>/dev/null
|
||||
while (( waited++ < 20 )); do
|
||||
state=$(<"/proc/$pid/stat") 2>/dev/null || break
|
||||
state=${state##*) }
|
||||
[[ ${state:0:1} != "Z" ]] || break
|
||||
sleep 0.1
|
||||
done
|
||||
kill -KILL "$pid" 2>/dev/null
|
||||
wait "$pid" 2>/dev/null
|
||||
}
|
||||
|
||||
start_screenrecording() {
|
||||
# A pid left by a recording that ended without a stop would otherwise hide
|
||||
# this one from the toggle until it has started and saved its own.
|
||||
rm -f "$REC_PID_FILE"
|
||||
local capture_args=()
|
||||
local target
|
||||
|
||||
@@ -317,7 +159,6 @@ start_screenrecording() {
|
||||
capture_args=(-w portal -s "${RESOLUTION:-$(default_resolution)}")
|
||||
else
|
||||
target=$(select_capture_target) || return 1
|
||||
start_cancelled && return 1
|
||||
|
||||
case $target in
|
||||
monitor:*)
|
||||
@@ -331,102 +172,51 @@ start_screenrecording() {
|
||||
fi
|
||||
|
||||
[[ $WEBCAM == "true" ]] && start_webcam_overlay "$target"
|
||||
start_cancelled && return 1
|
||||
|
||||
local filename="$OUTPUT_DIR/screenrecording-$(date +'%Y-%m-%d_%H-%M-%S').mp4"
|
||||
local backend pid=""
|
||||
local audio_devices=""
|
||||
local audio_args=()
|
||||
|
||||
for backend in $(recorder_backends); do
|
||||
[[ $backend == "wf-recorder" && $target == "portal" ]] && continue
|
||||
echo "===== $(date '+%F %T') args: $* backend: $backend target: $target =====" >>"$LOG_FILE"
|
||||
[[ $DESKTOP_AUDIO == "true" ]] && audio_devices+="default_output"
|
||||
|
||||
if [[ $backend == "wf-recorder" ]]; then
|
||||
launch_wf_recorder "$target" "$filename" || continue
|
||||
pid=$REC_PID
|
||||
else
|
||||
local audio_devices=""
|
||||
local audio_args=()
|
||||
if [[ $MICROPHONE_AUDIO == "true" ]]; then
|
||||
# Merge audio tracks into one - separate tracks only play one at a time in most players
|
||||
[[ -n $audio_devices ]] && audio_devices+="|"
|
||||
audio_devices+="default_input"
|
||||
fi
|
||||
|
||||
[[ $DESKTOP_AUDIO == "true" ]] && audio_devices+="default_output"
|
||||
[[ -n $audio_devices ]] && audio_args+=(-a "$audio_devices" -ac aac)
|
||||
|
||||
if [[ $MICROPHONE_AUDIO == "true" ]]; then
|
||||
# Merge audio tracks into one - separate tracks only play one at a time in most players
|
||||
[[ -n $audio_devices ]] && audio_devices+="|"
|
||||
audio_devices+="default_input"
|
||||
fi
|
||||
echo "===== $(date '+%F %T') args: $* target: $target =====" >>"$LOG_FILE"
|
||||
gpu-screen-recorder "${capture_args[@]}" -k auto -f 60 -fm cfr -fallback-cpu-encoding yes -o "$filename" "${audio_args[@]}" 2>>"$LOG_FILE" &
|
||||
local pid=$!
|
||||
|
||||
[[ -n $audio_devices ]] && audio_args+=(-a "$audio_devices" -ac aac)
|
||||
|
||||
gpu-screen-recorder "${capture_args[@]}" -k auto -f 60 -fm cfr -fallback-cpu-encoding yes -o "$filename" "${audio_args[@]}" 9>&- 2>>"$LOG_FILE" &
|
||||
pid=$!
|
||||
fi
|
||||
|
||||
local ticks=0
|
||||
while kill -0 $pid 2>/dev/null && [[ ! -f $filename ]]; do
|
||||
start_cancelled && break
|
||||
if [[ $target != "portal" ]] && (( ++ticks > READY_TIMEOUT_TICKS )); then
|
||||
end_launched_recorder "$pid"
|
||||
wf_audio_cleanup
|
||||
rm -f "$filename"
|
||||
continue 2
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
if start_cancelled; then
|
||||
end_launched_recorder "$pid"
|
||||
wf_audio_cleanup
|
||||
rm -f "$filename"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if kill -0 $pid 2>/dev/null; then
|
||||
echo "$pid" >"$REC_PID_FILE"
|
||||
echo "$filename" >"$RECORDING_FILE"
|
||||
toggle_screenrecording_indicator
|
||||
return 0
|
||||
fi
|
||||
|
||||
wf_audio_cleanup
|
||||
rm -f "$filename"
|
||||
while kill -0 $pid 2>/dev/null && [[ ! -f $filename ]]; do
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
return 1
|
||||
if kill -0 $pid 2>/dev/null; then
|
||||
echo "$filename" >"$RECORDING_FILE"
|
||||
toggle_screenrecording_indicator
|
||||
fi
|
||||
}
|
||||
|
||||
# Selects the recorder this script started, by the pid it saved. A saved pid
|
||||
# that is no longer a recorder is a recording that ended without a stop, so
|
||||
# nothing of ours is recording and no other recorder is touched. Without a
|
||||
# saved pid (a recording started before the pid was saved), every recorder the
|
||||
# user runs counts, as for the bar indicator and the menu.
|
||||
select_recorder() {
|
||||
RECORDER=()
|
||||
[[ -f $REC_PID_FILE ]] || return 0
|
||||
|
||||
local pid
|
||||
pid=$(<"$REC_PID_FILE")
|
||||
[[ $pid =~ ^[0-9]+$ ]] && omarchy-capture-screenrecording-process --pid "$pid" || return 1
|
||||
RECORDER=(--pid "$pid")
|
||||
}
|
||||
|
||||
# Stops the recorder screenrecording_active selected.
|
||||
stop_screenrecording() {
|
||||
omarchy-capture-screenrecording-process "${RECORDER[@]}" --signal INT # SIGINT required to save video properly
|
||||
pkill -SIGINT -f "^gpu-screen-recorder" # SIGINT required to save video properly
|
||||
|
||||
# Wait a maximum of 5 seconds to finish before hard killing
|
||||
local count=0
|
||||
while omarchy-capture-screenrecording-process "${RECORDER[@]}" && ((count < 50)); do
|
||||
while pgrep -f "^gpu-screen-recorder" >/dev/null && ((count < 50)); do
|
||||
sleep 0.1
|
||||
count=$((count + 1))
|
||||
done
|
||||
|
||||
toggle_screenrecording_indicator
|
||||
cleanup_webcam
|
||||
wf_audio_cleanup
|
||||
|
||||
if omarchy-capture-screenrecording-process "${RECORDER[@]}"; then
|
||||
omarchy-capture-screenrecording-process "${RECORDER[@]}" --signal KILL
|
||||
omarchy-notification-send -u critical -t 5000 "$(omarchy-text "Screen recording error")" "$(omarchy-text "Recording process had to be force-killed. Video may be corrupted.")"
|
||||
if pgrep -f "^gpu-screen-recorder" >/dev/null; then
|
||||
pkill -9 -f "^gpu-screen-recorder"
|
||||
omarchy-notification-send -u critical -t 5000 "Screen recording error" "Recording process had to be force-killed. Video may be corrupted."
|
||||
else
|
||||
finalize_recording
|
||||
local filename=$(cat "$RECORDING_FILE" 2>/dev/null)
|
||||
@@ -436,7 +226,7 @@ stop_screenrecording() {
|
||||
# Generate a preview thumbnail from the first frame
|
||||
ffmpeg -y -i "$filename" -ss 00:00:00.1 -vframes 1 -q:v 2 "$preview" -loglevel quiet 2>/dev/null
|
||||
|
||||
omarchy-notification-send "$(omarchy-text "Screen recording saved")" "$(omarchy-text "Open with Super + Alt + , (or click this)")" \
|
||||
omarchy-notification-send "Screen recording saved" "Open with Super + Alt + , (or click this)" \
|
||||
-t 10000 --image "${preview:-$filename}" \
|
||||
--exec mpv -- "$filename"
|
||||
|
||||
@@ -446,10 +236,10 @@ stop_screenrecording() {
|
||||
(
|
||||
sleep 2
|
||||
rm -f "$preview"
|
||||
) 9>&- &
|
||||
) &
|
||||
fi
|
||||
|
||||
rm -f "$RECORDING_FILE" "$REC_PID_FILE"
|
||||
rm -f "$RECORDING_FILE"
|
||||
}
|
||||
|
||||
toggle_screenrecording_indicator() {
|
||||
@@ -457,14 +247,7 @@ toggle_screenrecording_indicator() {
|
||||
}
|
||||
|
||||
screenrecording_active() {
|
||||
if select_recorder && omarchy-capture-screenrecording-process "${RECORDER[@]}"; then
|
||||
return 0
|
||||
else
|
||||
if [[ -f $REC_PID_FILE ]]; then
|
||||
wf_audio_cleanup
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
pgrep -f "^gpu-screen-recorder" >/dev/null
|
||||
}
|
||||
|
||||
finalize_recording() {
|
||||
@@ -485,10 +268,7 @@ finalize_recording() {
|
||||
# Hard-mute the first 400ms to drop the PipeWire capture-open pop (a near-clipping
|
||||
# transient around 130-200ms that a gentle fade-in can't attenuate enough), then a
|
||||
# 50ms fade avoids a click at the boundary before loudnorm normalizes the rest.
|
||||
#
|
||||
# loudnorm declares 192 kHz output, so without -ar the AAC encoder writes
|
||||
# 96 kHz from a 48 kHz recording.
|
||||
args+=(-ar 48000 -af "volume=enable='lt(t,0.4)':volume=0,afade=t=in:st=0.4:d=0.05,loudnorm=I=-14:TP=-1.5:LRA=11")
|
||||
args+=(-af "volume=enable='lt(t,0.4)':volume=0,afade=t=in:st=0.4:d=0.05,loudnorm=I=-14:TP=-1.5:LRA=11")
|
||||
fi
|
||||
|
||||
local processed="${latest%.mp4}-processed.mp4"
|
||||
@@ -499,66 +279,10 @@ finalize_recording() {
|
||||
fi
|
||||
}
|
||||
|
||||
mkdir -p "$RUNTIME_DIR" || exit 1
|
||||
# XDG_STATE_HOME may be outside a private home; protect existing fallbacks too.
|
||||
if [[ -z ${XDG_RUNTIME_DIR:-} ]]; then
|
||||
chmod 700 "$RUNTIME_DIR" || exit 1
|
||||
if screenrecording_active; then
|
||||
stop_screenrecording
|
||||
elif [[ $STOP_RECORDING == "true" ]]; then
|
||||
exit 1
|
||||
else
|
||||
start_screenrecording || cleanup_webcam
|
||||
fi
|
||||
|
||||
# Serialize changes to the recorder and its files; background children close 9.
|
||||
# A busy status query stays read-only so a picker cannot stall the bar.
|
||||
{
|
||||
if [[ $STATUS == "true" ]]; then
|
||||
if flock -n 9; then
|
||||
screenrecording_active
|
||||
else
|
||||
select_recorder && omarchy-capture-screenrecording-process "${RECORDER[@]}"
|
||||
fi
|
||||
else
|
||||
# A start still in its picker or waiting on its recorder is cancelled, not
|
||||
# queued behind: that toggle would otherwise begin another recording.
|
||||
# The start reads CANCEL_FILE only after it removes STARTING_FILE, so a
|
||||
# request left while STARTING_FILE stands is always seen. One that finds it
|
||||
# gone waits its turn, and a request the start used up by then is done.
|
||||
if ! flock -w 1 9; then
|
||||
touch "$CANCEL_FILE"
|
||||
# A start that was killed leaves its marker: it counts only while that
|
||||
# start still runs.
|
||||
starter=""
|
||||
[[ -r $STARTING_FILE ]] && starter=$(<"$STARTING_FILE")
|
||||
if [[ $starter =~ ^[0-9]+$ ]] && kill -0 "$starter" 2>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
flock 9 || exit 1
|
||||
[[ -e $CANCEL_FILE ]] || exit 0
|
||||
rm -f "$CANCEL_FILE"
|
||||
fi
|
||||
if screenrecording_active; then
|
||||
stop_screenrecording
|
||||
elif [[ $STOP_RECORDING == "true" ]]; then
|
||||
exit 1
|
||||
else
|
||||
rm -f "$CANCEL_FILE"
|
||||
echo $$ >"$STARTING_FILE"
|
||||
if start_screenrecording; then
|
||||
rm -f "$STARTING_FILE"
|
||||
# Cancelled while the recording was being saved as started.
|
||||
if start_cancelled; then
|
||||
end_launched_recorder "$(<"$REC_PID_FILE")"
|
||||
wf_audio_cleanup
|
||||
cleanup_webcam
|
||||
rm -f "$(<"$RECORDING_FILE")" "$REC_PID_FILE" "$RECORDING_FILE"
|
||||
toggle_screenrecording_indicator
|
||||
rm -f "$CANCEL_FILE"
|
||||
fi
|
||||
# A request after that check is left for its requester, who stops this
|
||||
# recording once it has the lock.
|
||||
else
|
||||
rm -f "$STARTING_FILE"
|
||||
wf_audio_cleanup
|
||||
cleanup_webcam
|
||||
rm -f "$CANCEL_FILE"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
} 9>>"$RUNTIME_DIR/omarchy-screenrecord.lock"
|
||||
@@ -1,72 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Check or signal the current user's recording processes
|
||||
# omarchy:args=[--pid <pid>] [--signal INT|KILL]
|
||||
# omarchy:hidden=true
|
||||
|
||||
usage() {
|
||||
echo "Usage: omarchy-capture-screenrecording-process [--pid <pid>]... [--signal INT|KILL]" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
proc_root=${OMARCHY_PROC_ROOT:-/proc}
|
||||
wanted=" "
|
||||
signal=""
|
||||
|
||||
while (($#)); do
|
||||
case $1 in
|
||||
--pid)
|
||||
# Compare textually first: bash arithmetic wraps values past 64 bits.
|
||||
[[ ${2-} =~ ^0*([0-9]+)$ ]] || usage
|
||||
pid=${BASH_REMATCH[1]}
|
||||
((${#pid} <= 10 && 10#$pid <= 4194304)) || usage
|
||||
wanted+="$((10#$pid)) "
|
||||
shift 2
|
||||
;;
|
||||
--signal)
|
||||
[[ ${2-} == "INT" || ${2-} == "KILL" ]] || usage
|
||||
signal=$2
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
is_recorder() {
|
||||
local exe
|
||||
exe=$(readlink "$proc_root/$1/exe" 2>/dev/null) || return 1
|
||||
# Linux appends this when an upgrade unlinks a still-running executable.
|
||||
exe=${exe% (deleted)}
|
||||
exe=${exe##*/}
|
||||
[[ $exe == "gpu-screen-recorder" || $exe == "wf-recorder" ]]
|
||||
}
|
||||
|
||||
# Linux truncates a process name to 15 characters: gpu-screen-recorder runs as
|
||||
# gpu-screen-reco, which any executable sharing that prefix also matches, and a
|
||||
# process can rename itself, so the executable path confirms each candidate.
|
||||
matched=()
|
||||
while read -r pid; do
|
||||
if [[ $wanted == " " || $wanted == *" $pid "* ]] && is_recorder "$pid"; then
|
||||
matched+=("$pid")
|
||||
fi
|
||||
done < <(pgrep -u "$UID" -x 'gpu-screen-reco(rder)?|wf-recorder')
|
||||
|
||||
if ((${#matched[@]} == 0)); then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n $signal ]]; then
|
||||
delivered=0
|
||||
for pid in "${matched[@]}"; do
|
||||
# The pid can be reused between discovery and kill; re-checking exe here narrows that window.
|
||||
if is_recorder "$pid" && kill "-$signal" "$pid" 2>/dev/null; then
|
||||
delivered=1
|
||||
fi
|
||||
done
|
||||
# A recorder that exits before its signal has stopped anyway; fail only if none was signalled.
|
||||
((delivered)) || exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
mapfile -t devices < <(omarchy-capture-webcam-list)
|
||||
if (( ${#devices[@]} == 0 )); then
|
||||
omarchy-notification-send "$(omarchy-text "No webcam devices found")" -u critical -t 3000
|
||||
omarchy-notification-send "No webcam devices found" -u critical -t 3000
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -2,22 +2,81 @@
|
||||
|
||||
# omarchy:summary=Take a screenshot
|
||||
# omarchy:group=capture
|
||||
# omarchy:args=[smart|region|windows|fullscreen|scroll] [copy|save]
|
||||
# omarchy:args=[smart|region|windows|fullscreen] [slurp|copy|save] [--editor=<name>]
|
||||
# omarchy:examples=omarchy screenshot | omarchy capture screenshot region
|
||||
# omarchy:aliases=omarchy screenshot
|
||||
|
||||
if [[ -n ${OMARCHY_SCREENSHOT_DIR:-} && -z ${OMASNAP_SCREENSHOT_DIR:-} ]]; then
|
||||
export OMASNAP_SCREENSHOT_DIR="$OMARCHY_SCREENSHOT_DIR"
|
||||
[[ -f ~/.config/user-dirs.dirs ]] && source ~/.config/user-dirs.dirs
|
||||
OUTPUT_DIR="${OMARCHY_SCREENSHOT_DIR:-${XDG_PICTURES_DIR:-$HOME/Pictures}}"
|
||||
|
||||
if [[ ! -d $OUTPUT_DIR ]]; then
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
omarchy-notification-send "Created screenshot directory: $OUTPUT_DIR" -t 2000
|
||||
fi
|
||||
|
||||
omasnap_args=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
copy) omasnap_args+=(--copy) ;;
|
||||
save) omasnap_args+=(--save) ;;
|
||||
slurp) ;;
|
||||
*) omasnap_args+=("$arg") ;;
|
||||
esac
|
||||
done
|
||||
pkill slurp && exit 0
|
||||
|
||||
exec omasnap "${omasnap_args[@]}"
|
||||
SCREENSHOT_EDITOR="${OMARCHY_SCREENSHOT_EDITOR:-tensaku-edit}"
|
||||
|
||||
# Parse --editor flag from any position
|
||||
ARGS=()
|
||||
for arg in "$@"; do
|
||||
if [[ $arg == --editor=* ]]; then
|
||||
SCREENSHOT_EDITOR="${arg#--editor=}"
|
||||
else
|
||||
ARGS+=("$arg")
|
||||
fi
|
||||
done
|
||||
set -- "${ARGS[@]}"
|
||||
|
||||
MODE="${1:-smart}"
|
||||
PROCESSING="${2:-slurp}"
|
||||
|
||||
# The picker leaves the screen freeze running (PID on its first output line)
|
||||
# so grim captures the frozen overlay rather than live content shifting
|
||||
# during teardown.
|
||||
#
|
||||
# Software-composited cursors (Hyprland's fallback on GPUs without working
|
||||
# hardware cursors) are baked into the frames grim captures, so force
|
||||
# hardware cursors until after grim runs and restore the setting on exit.
|
||||
NO_HW_CURSORS=$(hyprctl getoption cursor:no_hardware_cursors -j | jq '.int')
|
||||
|
||||
set_no_hw_cursors() {
|
||||
hyprctl eval "hl.config({ cursor = { no_hardware_cursors = $1 } })" &>/dev/null ||
|
||||
hyprctl keyword cursor:no_hardware_cursors "$1" &>/dev/null
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
[[ -n $FREEZE_PID ]] && kill $FREEZE_PID 2>/dev/null
|
||||
set_no_hw_cursors "$NO_HW_CURSORS"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
set_no_hw_cursors 0
|
||||
{ read -r FREEZE_PID; read -r SELECTION; } < <(omarchy-capture-region "$MODE" --keep-freeze)
|
||||
|
||||
[[ -z $SELECTION ]] && exit 0
|
||||
|
||||
FILENAME="screenshot-$(date +'%Y-%m-%d_%H-%M-%S').png"
|
||||
FILEPATH="$OUTPUT_DIR/$FILENAME"
|
||||
|
||||
case "$PROCESSING" in
|
||||
slurp)
|
||||
grim -g "$SELECTION" "$FILEPATH" || exit 1
|
||||
echo "$FILEPATH"
|
||||
wl-copy --type image/png <"$FILEPATH"
|
||||
|
||||
# Best-effort: the screenshot is already saved and on the clipboard, so a
|
||||
# notification outage must not report the capture itself as failed.
|
||||
omarchy-notification-send "Screenshot saved to clipboard and file" "Edit with Super + Alt + , (or click this)" \
|
||||
--image "$FILEPATH" \
|
||||
--exec "$SCREENSHOT_EDITOR" "$FILEPATH" || true
|
||||
;;
|
||||
copy)
|
||||
grim -g "$SELECTION" - | wl-copy --type image/png
|
||||
;;
|
||||
save)
|
||||
grim -g "$SELECTION" "$FILEPATH" || exit 1
|
||||
echo "$FILEPATH"
|
||||
;;
|
||||
esac
|
||||
@@ -23,4 +23,4 @@ TEXT=$(grim -g "$SELECTION" - | tesseract stdin stdout --oem 1 --psm 6 -l "${OMA
|
||||
[[ -z $TEXT ]] && exit 1
|
||||
|
||||
printf "%s" "$TEXT" | wl-copy
|
||||
omarchy-notification-send -g "$(omarchy-text "Copied text from selection to clipboard")"
|
||||
omarchy-notification-send -g "Copied text from selection to clipboard"
|
||||
@@ -8,10 +8,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
readonly MARGIN=40
|
||||
# Has to resolve to whatever omarchy-capture-screenrecording writes, fallback
|
||||
# included, or a recording without a session runtime dir anchors the camera to
|
||||
# the whole monitor instead of the region it recorded.
|
||||
readonly REGION_FILE="${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/omarchy}/omarchy-screenrecord-region"
|
||||
readonly REGION_FILE="${XDG_RUNTIME_DIR:-/tmp}/omarchy-screenrecord-region"
|
||||
|
||||
usage() {
|
||||
echo "Usage: omarchy-capture-webcam-resize <smaller|larger|reset|small|medium|large>" >&2
|
||||
|
||||
+10
-89
@@ -1,25 +1,10 @@
|
||||
#!/bin/bash -p
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Set the Omarchy package channel.
|
||||
# omarchy:args=<stable|rc|edge|dev>
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
if [[ $- != *p* ]]; then
|
||||
echo "Refusing an unsafe Bash startup for channel switching." >&2
|
||||
exit 126
|
||||
fi
|
||||
security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126
|
||||
source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126
|
||||
omarchy_security_require_privileged_bash_startup || exit 126
|
||||
set -euo pipefail
|
||||
omarchy_security_sanitize_bash_environment "$0" "$@"
|
||||
omarchy_security_require_source_root "$0"
|
||||
user_path=$PATH
|
||||
# Traps first, so a signal or failure during the entry revocation still
|
||||
# exits through the cleanup path.
|
||||
omarchy_security_install_sudo_cleanup_traps
|
||||
omarchy_security_revoke_sudo_timestamp || exit 1
|
||||
omarchy_security_enable_no_update_sudo
|
||||
|
||||
usage() { echo "Usage: omarchy-channel-set [stable|rc|edge|dev]"; }
|
||||
fail() { echo "Error: $*" >&2; exit 1; }
|
||||
@@ -48,57 +33,12 @@ validate_dev_checkout() {
|
||||
}
|
||||
|
||||
link_dev_checkout() {
|
||||
local checkout="$1" required
|
||||
[[ -d $checkout/.git ]] || git clone https://github.com/omacom/omarchy.git "$checkout"
|
||||
|
||||
# Check the destination before changing /etc/omarchy.conf or sudo's path.
|
||||
# An existing checkout is not pulled automatically and may predate this policy.
|
||||
for required in bin/omarchy-security-functions bin/omarchy-update bin/omarchy-refresh-pacman default/omarchy/sudo-no-update/sudo; do
|
||||
if [[ ! -f $checkout/$required || ! -r $checkout/$required ||
|
||||
( $required != "bin/omarchy-security-functions" && ! -x $checkout/$required ) ]]; then
|
||||
fail "Update the checkout before switching to dev; missing required update support in $required."
|
||||
fi
|
||||
done
|
||||
|
||||
# The checkout's own channel refresh runs next, from its own templates.
|
||||
if [[ ! -f $checkout/${templates#"$OMARCHY_PATH"/}/pacman-$pacman_channel.conf ||
|
||||
! -f $checkout/${templates#"$OMARCHY_PATH"/}/mirrorlist-$pacman_channel ]]; then
|
||||
fail "Update the checkout before switching to dev; it has no $pacman_channel templates for $platform."
|
||||
fi
|
||||
|
||||
# On aarch64 one from before it had platform templates would replace this
|
||||
# machine's repositories with x86's.
|
||||
if [[ $platform != "x86" ]]; then
|
||||
for required in bin/omarchy-hw-platform install/helpers/pacman.sh; do
|
||||
if [[ ! -f $checkout/$required || ! -r $checkout/$required ||
|
||||
( $required == "bin/omarchy-hw-platform" && ! -x $checkout/$required ) ]]; then
|
||||
fail "Update the checkout before switching to dev; on $platform it needs $required for this machine's repositories."
|
||||
fi
|
||||
done
|
||||
fi
|
||||
local checkout="$1"
|
||||
[[ -d $checkout/.git ]] || git clone https://github.com/basecamp/omarchy.git "$checkout"
|
||||
|
||||
omarchy-dev-link "$checkout" --no-reboot
|
||||
}
|
||||
|
||||
# A packaged destination cannot be inspected before its package is installed,
|
||||
# and a package transaction can replace the running tree with a release that
|
||||
# predates the command-scoped wrapper. After that, a bare sudo would resolve to
|
||||
# /usr/bin/sudo and publish a timestamp, and the destination's own updater
|
||||
# authenticates the same way. Neither may run from a flow that has just run
|
||||
# user hooks: stop at a consistent point and say how to finish from a fresh
|
||||
# session.
|
||||
stop_for_older_destination() {
|
||||
cat >&2 <<EOF
|
||||
The destination predates command-scoped sudo, so this switch stops before its update.
|
||||
Packages are switched. Run 'omarchy update' from a new terminal to finish, then reboot if prompted.
|
||||
EOF
|
||||
exit 3
|
||||
}
|
||||
|
||||
wrapper_present() {
|
||||
[[ -f $OMARCHY_PATH/default/omarchy/sudo-no-update/sudo && -x $OMARCHY_PATH/default/omarchy/sudo-no-update/sudo ]]
|
||||
}
|
||||
|
||||
(( $# > 0 )) || { usage; exit 1; }
|
||||
|
||||
dev_checkout=""
|
||||
@@ -119,6 +59,9 @@ case "$channel" in
|
||||
packages=(omarchy-dev omarchy-settings-dev)
|
||||
;;
|
||||
dev)
|
||||
confirm_dev || { echo "Cancelled."; exit 0; }
|
||||
dev_checkout="$HOME/omarchy"
|
||||
validate_dev_checkout "$dev_checkout"
|
||||
pacman_channel=edge
|
||||
packages=(omarchy-dev omarchy-settings-dev)
|
||||
;;
|
||||
@@ -129,24 +72,6 @@ case "$channel" in
|
||||
;;
|
||||
esac
|
||||
|
||||
# A channel this machine's platform has no templates for is refused before
|
||||
# anything changes (install/helpers/pacman.sh).
|
||||
source "$OMARCHY_PATH/install/helpers/pacman.sh"
|
||||
platform=$(omarchy-hw-platform) || fail "Cannot tell this machine's platform; nothing changed."
|
||||
templates=$(omarchy_pacman_templates "$platform") || exit 1
|
||||
[[ -f $templates/pacman-$pacman_channel.conf && -f $templates/mirrorlist-$pacman_channel ]] ||
|
||||
fail "Omarchy has no $pacman_channel channel for $platform; nothing changed."
|
||||
|
||||
if [[ $channel == "dev" ]]; then
|
||||
confirm_dev || { echo "Cancelled."; exit 0; }
|
||||
dev_checkout="$HOME/omarchy"
|
||||
validate_dev_checkout "$dev_checkout"
|
||||
fi
|
||||
|
||||
# A failure past this point leaves the channel switch half-applied, so say how
|
||||
# to pick it back up rather than dying silently under set -e.
|
||||
trap 'echo -e "\nThe channel switch did not complete. Review the error above, then rerun: omarchy-channel-set '"$channel"'" >&2' ERR
|
||||
|
||||
if [[ -z $dev_checkout && $OMARCHY_PATH != "/usr/share/omarchy" ]]; then
|
||||
leaving_dev=1
|
||||
fi
|
||||
@@ -154,16 +79,13 @@ fi
|
||||
if [[ -n $dev_checkout ]]; then
|
||||
link_dev_checkout "$dev_checkout"
|
||||
export OMARCHY_PATH="$dev_checkout"
|
||||
omarchy_security_enable_no_update_sudo
|
||||
export PATH="$OMARCHY_PATH/bin:$PATH"
|
||||
omarchy-state set reboot-required
|
||||
fi
|
||||
|
||||
OMARCHY_UPDATE_USER_PATH="$user_path" omarchy-refresh-pacman "$pacman_channel"
|
||||
# Each transaction can have replaced this tree; check before the next sudo.
|
||||
wrapper_present || stop_for_older_destination
|
||||
omarchy-refresh-pacman "$pacman_channel"
|
||||
# --ask 4 accepts omarchy <-> omarchy-dev replacement prompts without file overwrites.
|
||||
omarchy-update-pacman -S --needed --noconfirm --ask 4 "${packages[@]}"
|
||||
wrapper_present || stop_for_older_destination
|
||||
sudo env OMARCHY_UPDATE_PACMAN=1 pacman -S --needed --noconfirm --ask 4 "${packages[@]}"
|
||||
|
||||
if [[ -z $dev_checkout ]]; then
|
||||
omarchy-dev-unlink --no-reboot
|
||||
@@ -172,7 +94,6 @@ if [[ -z $dev_checkout ]]; then
|
||||
if (( leaving_dev )); then
|
||||
omarchy-state set reboot-required
|
||||
fi
|
||||
omarchy_security_enable_no_update_sudo 2>/dev/null || stop_for_older_destination
|
||||
fi
|
||||
|
||||
OMARCHY_UPDATE_USER_PATH="$user_path" "$OMARCHY_PATH/bin/omarchy-update" -y
|
||||
omarchy-update -y
|
||||
@@ -18,7 +18,7 @@ copy_url() {
|
||||
|
||||
[[ -n $url ]] || return 1
|
||||
printf '%s' "$url" | wl-copy --type text/plain
|
||||
omarchy-notification-send -g "$(omarchy-text "URL copied to clipboard")"
|
||||
omarchy-notification-send -g "URL copied to clipboard"
|
||||
}
|
||||
|
||||
reply_copied() {
|
||||
|
||||
@@ -87,7 +87,7 @@ download_url() {
|
||||
|
||||
# Don't show anything until yt-dlp confirms there's actually a video to grab.
|
||||
if ! yt-dlp --no-playlist --simulate --quiet --no-warnings --no-exec --no-exec-before-download -- "$url" >/dev/null 2>&1; then
|
||||
omarchy-notification-send -u critical -g "$(omarchy-text "No video found for download")" "$url"
|
||||
omarchy-notification-send -u critical -g "No video found for download" "$url"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -148,7 +148,7 @@ download_url() {
|
||||
# toast would exit before the thumbnail cleanup below is ever scheduled.
|
||||
# `--` keeps mpv from parsing a leading-dash filename as an option; the path
|
||||
# is one discrete argument, so it never reaches a shell.
|
||||
omarchy-notification-send -g "$(omarchy-text "Download complete")" "$title" \
|
||||
omarchy-notification-send -g "Download complete" "$title" \
|
||||
-t 10000 --image "${preview:-$filepath}" \
|
||||
--exec mpv -- "$filepath" || true
|
||||
|
||||
@@ -160,7 +160,7 @@ download_url() {
|
||||
rm -f "$preview"
|
||||
) &
|
||||
else
|
||||
omarchy-notification-send -u critical -g "$(omarchy-text "Download failed")" "$url"
|
||||
omarchy-notification-send -u critical -g "Download failed" "$url"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -30,7 +30,7 @@ open_image() {
|
||||
local path="$1"
|
||||
|
||||
[[ -r $path ]] || exit 1
|
||||
exec omasnap "$path"
|
||||
exec tensaku-edit "$path"
|
||||
}
|
||||
|
||||
open_text() {
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Hand a command line to the running Chromium-based browser
|
||||
# omarchy:args=<browser-command> [browser-args...]
|
||||
# omarchy:hidden=true
|
||||
|
||||
# A second browser process spends ~165ms starting up only to pass its command
|
||||
# line to the running one over the profile's singleton socket, then exits. Send
|
||||
# it there directly, and fail when no browser acknowledges it so the caller can
|
||||
# launch one instead.
|
||||
config="${XDG_CONFIG_HOME:-$HOME/.config}"
|
||||
|
||||
# Only the browsers themselves: a wrapper in their place may pick a profile or
|
||||
# add flags of its own, which only running it can honour.
|
||||
case $(type -P "$1") in
|
||||
/usr/bin/chromium) profile=chromium flags=chromium-flags.conf ;;
|
||||
/usr/bin/google-chrome-stable) profile=google-chrome flags=chrome-flags.conf ;;
|
||||
/usr/bin/brave) profile=BraveSoftware/Brave-Browser flags=brave-flags.conf ;;
|
||||
/usr/bin/brave-origin) profile=BraveSoftware/Brave-Origin flags=brave-origin-flags.conf ;;
|
||||
/usr/bin/microsoft-edge-stable) profile=microsoft-edge flags=microsoft-edge-stable-flags.conf ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
|
||||
# The running browser already has the process flags Omarchy ships in the flags
|
||||
# file. Any other flag there may be meant for each launch, and a data directory
|
||||
# from the environment is another browser, so leave those to the launcher.
|
||||
if [[ -n ${CHROME_USER_DATA_DIR:-}${CHROME_CONFIG_HOME:-} ]] ||
|
||||
cat "/etc/$flags" "$config/$flags" 2>/dev/null | sed 's/#.*//' | tr -s '[:space:]' '\n' |
|
||||
grep -vE '^(--(ozone-platform|ozone-platform-hint|password-store|enable-features|load-extension)=.*)?$' | grep -q .; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for arg in "$@"; do
|
||||
[[ $arg == --user-data-dir* ]] && exit 1
|
||||
done
|
||||
|
||||
# The socket lives in a /tmp dir, which another user could recreate once a
|
||||
# crashed browser's is cleaned away, so only a socket of our own gets the URL.
|
||||
socket="$config/$profile/SingletonSocket"
|
||||
[[ -S $socket && -O $socket ]] || exit 1
|
||||
|
||||
reply=$({
|
||||
printf 'START\0%s\0browser' "$PWD"
|
||||
printf '\0%s' "$@"
|
||||
} | socat -t2 - "UNIX-CONNECT:$socket" 2>/dev/null)
|
||||
|
||||
[[ $reply == "ACK" ]]
|
||||
@@ -1,58 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Print the desktop entry ID of the default web browser
|
||||
# omarchy:hidden=true
|
||||
|
||||
# Read the http handler from the config-dir mimeapps.list files the way xdg-mime
|
||||
# does, desktop-specific list first, taking the first listed entry that is
|
||||
# installed. xdg-settings reaches the same answer through several shell scripts
|
||||
# in ~70ms, which every browser and web app launch would otherwise pay.
|
||||
IFS=: read -ra data_dirs <<<"${XDG_DATA_HOME:-$HOME/.local/share}:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"
|
||||
IFS=: read -ra config_dirs <<<"${XDG_CONFIG_HOME:-$HOME/.config}:${XDG_CONFIG_DIRS:-/etc/xdg}"
|
||||
IFS=: read -ra desktops <<<"${XDG_CURRENT_DESKTOP:-}"
|
||||
|
||||
# Installed means the command of the entry the launchers will read, the first
|
||||
# one found, still resolves: an entry of Omarchy's own can outlive the browser
|
||||
# it launched.
|
||||
installed() {
|
||||
local data_dir command
|
||||
|
||||
for data_dir in "${data_dirs[@]}"; do
|
||||
[[ -r $data_dir/applications/$1 ]] || continue
|
||||
command=$(sed -n 's/^Exec=\([^ ]*\).*/\1/p' "$data_dir/applications/$1" | head -1)
|
||||
[[ -n $command ]] && command -v "$command" >/dev/null
|
||||
return
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
for config_dir in "${config_dirs[@]}"; do
|
||||
for desktop in "${desktops[@]}" ""; do
|
||||
list="$config_dir/${desktop:+${desktop,,}-}mimeapps.list"
|
||||
[[ -f $list ]] || continue
|
||||
|
||||
entries=$(awk '
|
||||
/^\[Default Applications\]/ { section = 1; next }
|
||||
/^\[/ { section = 0 }
|
||||
section && index($0, "x-scheme-handler/http=") == 1 { print substr($0, 23); exit }
|
||||
' "$list")
|
||||
|
||||
IFS=';' read -ra entries <<<"$entries"
|
||||
for entry in "${entries[@]}"; do
|
||||
if [[ -n $entry ]] && installed "$entry"; then
|
||||
echo "$entry"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
done
|
||||
done
|
||||
|
||||
# Nothing set in a config dir: let xdg-settings search the data dirs and caches.
|
||||
# It consults $BROWSER too, which the session points back at omarchy-launch-browser.
|
||||
browser=$(env -u BROWSER xdg-settings get default-web-browser 2>/dev/null)
|
||||
if [[ -z $browser ]]; then
|
||||
browser=$(xdg-mime query default x-scheme-handler/https 2>/dev/null)
|
||||
fi
|
||||
|
||||
[[ -n $browser ]] && echo "$browser"
|
||||
@@ -3,7 +3,7 @@
|
||||
# omarchy:summary=Check whether any required commands are missing
|
||||
|
||||
for cmd in "$@"; do
|
||||
if ! command -v -- "$cmd" &>/dev/null; then
|
||||
if ! command -v "$cmd" &>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# omarchy:summary=Check whether all required commands are available
|
||||
|
||||
for cmd in "$@"; do
|
||||
command -v -- "$cmd" &>/dev/null || exit 1
|
||||
command -v "$cmd" &>/dev/null || exit 1
|
||||
done
|
||||
|
||||
exit 0
|
||||
@@ -1,10 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Print the current working directory of the active terminal window
|
||||
# omarchy:args=[terminal-pid]
|
||||
# omarchy:hidden=true
|
||||
|
||||
terminal_pid=${1:-$(hyprctl activewindow | awk '/pid:/ {print $2}')}
|
||||
terminal_pid=$(hyprctl activewindow | awk '/pid:/ {print $2}')
|
||||
kitty_socket="$XDG_RUNTIME_DIR/omarchy-kitty-$terminal_pid"
|
||||
cwd=""
|
||||
|
||||
@@ -12,16 +11,7 @@ if [[ -S $kitty_socket ]]; then
|
||||
cwd=$(kitten @ --to "unix:$kitty_socket" ls --match "state:focused" 2>/dev/null |
|
||||
jq -r '.[].tabs[].windows[].cwd // empty')
|
||||
else
|
||||
# The newest child, as pgrep -P | tail -n1 found it, read from the kernel's
|
||||
# child lists: pgrep scans all of /proc on every new terminal.
|
||||
shell_pid=""
|
||||
for children_file in /proc/"$terminal_pid"/task/*/children; do
|
||||
[[ -r $children_file ]] || continue
|
||||
read -ra children <"$children_file" || true
|
||||
for child in "${children[@]}"; do
|
||||
(( child > ${shell_pid:-0} )) && shell_pid=$child
|
||||
done
|
||||
done
|
||||
shell_pid=$(pgrep -P "$terminal_pid" | tail -n1)
|
||||
|
||||
if [[ -n $shell_pid ]]; then
|
||||
cwd=$(readlink -f "/proc/$shell_pid/cwd" 2>/dev/null)
|
||||
|
||||
@@ -40,8 +40,8 @@ announce() {
|
||||
omarchy-notification-send \
|
||||
--urgency critical \
|
||||
--glyph "$CRASH_GLYPH" \
|
||||
"$(omarchy-text "Process crashed: %1" "$comm")" \
|
||||
"$(omarchy-text "Click to diagnose with AI")" \
|
||||
"Process crashed: $comm" \
|
||||
"Click to diagnose with AI" \
|
||||
--exec omarchy-agent-crash "$pid" "$comm" "$exe" "$signal"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Set and launch the default coding agent
|
||||
# omarchy:args=[pi|omp|opencode|ori|claude|codex|grok|openclaw|agy|hermes|copilot|crush|cursor-agent|muse]
|
||||
# omarchy:args=[pi|omp|opencode|ori|claude|codex|grok|agy|copilot|crush|dim|kimi]
|
||||
# omarchy:examples=omarchy default agent | omarchy default agent codex | omarchy default agent claude
|
||||
|
||||
installing=false
|
||||
@@ -31,57 +31,25 @@ ori | openrouter) agent="ori"; name="Ori"; agent_package="github:OpenRouterLabs/
|
||||
claude | claude-code) agent="claude"; name="Claude Code" ;;
|
||||
codex) agent="codex"; name="Codex" ;;
|
||||
crush) agent="crush"; name="Crush" ;;
|
||||
grok) agent="grok"; name="Grok" ;;
|
||||
openclaw) agent="openclaw"; name="OpenClaw"; agent_installer="omarchy-install-openclaw-cli" ;;
|
||||
dim | dimcode | dim-agent) agent="dim"; name="Dim Agent"; agent_package="npm:dimcode" ;;
|
||||
grok) agent="grok"; name="Grok"; agent_package="npm:@xai-official/grok" ;;
|
||||
kimi | kimi-code) agent="kimi"; name="Kimi Code"; agent_package="npm:@moonshot-ai/kimi-code" ;;
|
||||
agy | antigravity | antigravity-cli | gemini | gemini-cli) agent="agy"; name="Antigravity"; agent_package="antigravity-cli" ;;
|
||||
hermes) agent="hermes"; name="Hermes"; agent_installer="omarchy-install-hermes-cli" ;;
|
||||
copilot | github-copilot) agent="copilot"; name="GitHub Copilot" ;;
|
||||
muse | muse-code | musecode)
|
||||
agent="muse"; name="Muse Code"
|
||||
# Meta's launcher verifies and updates the native binary for this platform.
|
||||
agent_package="http:muse[url=https://api.meta.ai/muse-launcher.sh,bin=muse,version_list_url=https://api.meta.ai/muse-code/channels/muse-stable,version_json_path=.version]"
|
||||
;;
|
||||
cursor | cursor-agent) agent="cursor-agent"; name="Cursor CLI" ;;
|
||||
*)
|
||||
echo "Usage: omarchy-default-agent <pi|omp|opencode|ori|claude|codex|grok|openclaw|agy|hermes|copilot|crush|cursor-agent|muse>"
|
||||
echo "Usage: omarchy-default-agent <pi|omp|opencode|ori|claude|codex|grok|agy|copilot|crush|dim|kimi>"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
agent_package=${agent_package:-$agent}
|
||||
|
||||
# Hermes and OpenClaw are not mise tools: each is a self-updating runtime in
|
||||
# the user's home, Hermes the desktop app's and OpenClaw the one its Control
|
||||
# UI updates. Each has its own installer with the same --check/--now contract
|
||||
# mise-backed agents get from mise; see omarchy-install-hermes-cli and
|
||||
# omarchy-install-openclaw-cli.
|
||||
if [[ -n ${agent_installer:-} ]]; then
|
||||
# Not omarchy-cmd-present: a command on PATH says nothing about whether the
|
||||
# install behind it finished or runs the sessions omarchy-agent starts.
|
||||
# Treating it as installed would skip the floating terminal and run a
|
||||
# minutes-long install inside the menu action instead.
|
||||
agent_present() { "$agent_installer" --check; }
|
||||
agent_install() { "$agent_installer" --now; }
|
||||
else
|
||||
# Anything at the wrapper's path other than the wrapper is the user's own
|
||||
# install, such as the symlink Cursor's installer leaves. A mise copy would
|
||||
# only shadow it, since the mise shims precede ~/.local/bin on PATH.
|
||||
user_install() {
|
||||
[[ -x $HOME/.local/bin/$agent ]] &&
|
||||
{ [[ -L $HOME/.local/bin/$agent ]] || ! grep -q '^mise use -g' "$HOME/.local/bin/$agent"; }
|
||||
}
|
||||
agent_present() { user_install || mise where "$agent_package" &>/dev/null; }
|
||||
agent_install() { user_install || mise use -g "$agent_package"; }
|
||||
fi
|
||||
|
||||
if [[ $installing == "false" ]] && ! agent_present; then
|
||||
if [[ $installing == "false" ]] && ! mise where "$agent_package" &>/dev/null; then
|
||||
exec omarchy-launch-floating-terminal-with-presentation omarchy-default-agent --install "$agent"
|
||||
fi
|
||||
|
||||
if ! agent_install; then
|
||||
if [[ $installing == "true" && -n ${agent_installer:-} ]]; then
|
||||
echo "Could not install $name" >&2
|
||||
elif [[ $installing == "true" ]]; then
|
||||
if ! mise use -g "$agent_package"; then
|
||||
if [[ $installing == "true" ]]; then
|
||||
echo "Could not install $name with mise" >&2
|
||||
else
|
||||
echo "Could not set $name as the default coding agent" >&2
|
||||
@@ -89,10 +57,6 @@ if ! agent_install; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ $agent == "claude" ]]; then
|
||||
omarchy-install-chromium-claude 2>/dev/null || true
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$agent_file")"
|
||||
printf '%s\n' "$agent" >"$agent_file"
|
||||
|
||||
|
||||
@@ -48,4 +48,4 @@ fi
|
||||
|
||||
env -u BROWSER xdg-settings set default-web-browser "$desktop_id" || exit 1
|
||||
|
||||
omarchy-notification-send -g $glyph "$(omarchy-text "%1 is now the default browser" "$name")"
|
||||
omarchy-notification-send -g $glyph "$name is now the default browser"
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Get or set the default dictation backend
|
||||
# omarchy:args=[backend]
|
||||
# omarchy:examples=omarchy default dictation | omarchy default dictation superwhisper
|
||||
|
||||
if (($# > 1)); then
|
||||
echo "Usage: omarchy default dictation [backend]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if (($# == 0)); then
|
||||
config="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/defaults/dictation"
|
||||
if [[ ! -s $config ]]; then
|
||||
echo "No dictation backend configured. Select one in Setup > Defaults > Dictation." >&2
|
||||
exit 1
|
||||
fi
|
||||
read -r backend < "$config"
|
||||
else
|
||||
backend=$1
|
||||
fi
|
||||
|
||||
if [[ ! $backend =~ ^[a-z0-9][a-z0-9-]*$ ]]; then
|
||||
echo "Invalid dictation backend: $backend" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if (($# == 0)); then
|
||||
echo "$backend"
|
||||
else
|
||||
installer="omarchy-install-dictation-$backend"
|
||||
if omarchy-cmd-missing "$installer"; then
|
||||
echo "No installer available for dictation backend: $backend" >&2
|
||||
exit 1
|
||||
fi
|
||||
omarchy-launch-floating-terminal-with-presentation "$installer"
|
||||
fi
|
||||
@@ -17,9 +17,6 @@ if (($# == 0)); then
|
||||
read -r editor <"$editor_file"
|
||||
fi
|
||||
|
||||
# sublime-text-4 ships `subl`; older defaults stored `sublime_text`.
|
||||
[[ $editor == sublime_text ]] && editor=subl
|
||||
|
||||
[[ -n $editor ]] && echo "$editor" || echo "nvim"
|
||||
exit 0
|
||||
fi
|
||||
@@ -28,10 +25,10 @@ case "$1" in
|
||||
code) selection="code"; editor="code"; name="VSCode"; glyph= ;;
|
||||
cursor) selection="cursor"; editor="cursor"; name="Cursor"; glyph= ;;
|
||||
zed | zeditor) selection="zed"; editor="zeditor"; name="Zed"; glyph= ;;
|
||||
sublime_text | subl) selection="sublime_text"; editor="subl"; name="Sublime Text"; glyph= ;;
|
||||
sublime_text) selection="sublime_text"; editor="sublime_text"; name="Sublime Text"; glyph= ;;
|
||||
helix) selection="helix"; editor="helix"; name="Helix"; glyph= ;;
|
||||
vim) selection="vim"; editor="vim"; name="Vim"; glyph= ;;
|
||||
emacs) selection="emacs"; editor="emacs"; name="Emacs"; glyph= ;;
|
||||
emacs) selection="emacs"; editor="emacs"; name="Emacs"; glyph= ;;
|
||||
nvim) selection="nvim"; editor="nvim"; name="Neovim"; glyph= ;;
|
||||
*)
|
||||
echo "Usage: omarchy-default-editor <code|cursor|zed|sublime_text|helix|vim|emacs|nvim>"
|
||||
@@ -59,4 +56,4 @@ fi
|
||||
mkdir -p "$(dirname "$editor_file")"
|
||||
printf '%s\n' "$editor" >"$editor_file"
|
||||
|
||||
omarchy-notification-send -g $glyph "$(omarchy-text "%1 is now the default editor" "$name")"
|
||||
omarchy-notification-send -g $glyph "$name is now the default editor"
|
||||
@@ -48,4 +48,4 @@ cat >~/.config/xdg-terminals.list <<EOF
|
||||
$desktop_id
|
||||
EOF
|
||||
|
||||
omarchy-notification-send -g $glyph "$(omarchy-text "%1 is now the default terminal" "$name")"
|
||||
omarchy-notification-send -g $glyph "$name is now the default terminal"
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Record dictation with the configured backend
|
||||
# omarchy:args=<start|stop|toggle>
|
||||
# omarchy:examples=omarchy dictation start | omarchy dictation toggle
|
||||
|
||||
if (($# != 1)) || [[ $1 != "start" && $1 != "stop" && $1 != "toggle" ]]; then
|
||||
echo "Usage: omarchy dictation <start|stop|toggle>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
config="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/defaults/dictation"
|
||||
mkdir -p "${config%/*}"
|
||||
exec 9> "$config.lock"
|
||||
if ! flock -w 5 9; then
|
||||
echo "Dictation is busy; try again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
backend=$(omarchy-default-dictation) || exit 1
|
||||
adapter="omarchy-dictation-$backend"
|
||||
if omarchy-cmd-missing "$adapter"; then
|
||||
echo "The selected dictation backend ($backend) is not installed." >&2
|
||||
exit 1
|
||||
fi
|
||||
"$adapter" "$1" 9>&-
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Record dictation using Superwhisper
|
||||
# omarchy:args=<start|stop|toggle>
|
||||
|
||||
case "${1:-}" in
|
||||
start) superwhisper start ;;
|
||||
stop)
|
||||
if omarchy-cmd-missing superwhisper || ! systemctl --user is-active --quiet superwhisper.service; then
|
||||
exit 0
|
||||
else
|
||||
superwhisper stop
|
||||
fi
|
||||
;;
|
||||
toggle) superwhisper record ;;
|
||||
*) echo "Usage: omarchy dictation superwhisper <start|stop|toggle>" >&2; exit 1 ;;
|
||||
esac
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:hidden=true
|
||||
# omarchy:summary=Save a configured dictation backend after stopping the previous backend
|
||||
# omarchy:args=<backend>
|
||||
|
||||
set -e
|
||||
|
||||
if (($# != 1)) || [[ ! $1 =~ ^[a-z0-9][a-z0-9-]*$ ]]; then
|
||||
echo "Usage: omarchy-dictation-use <backend>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
config="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/defaults/dictation"
|
||||
mkdir -p "${config%/*}"
|
||||
exec 9> "$config.lock"
|
||||
if ! flock -w 5 9; then
|
||||
echo "Dictation is busy; try again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
previous=$(omarchy-default-dictation 2>/dev/null) || previous=""
|
||||
if [[ -n $previous && $previous != "$1" ]] && omarchy-cmd-present "omarchy-dictation-$previous"; then
|
||||
if ! "omarchy-dictation-$previous" stop 9>&-; then
|
||||
echo "Could not stop $previous; keeping the existing dictation backend." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
temporary=$(mktemp "$config.XXXXXX")
|
||||
trap 'rm -f "$temporary"' EXIT
|
||||
printf '%s\n' "$1" > "$temporary"
|
||||
mv -f "$temporary" "$config"
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Record dictation using Voxtype
|
||||
# omarchy:args=<start|stop|toggle>
|
||||
|
||||
case "${1:-}" in
|
||||
start|toggle) voxtype record "$1" ;;
|
||||
stop)
|
||||
if omarchy-cmd-missing voxtype || [[ $(voxtype status) == "stopped" ]]; then
|
||||
exit 0
|
||||
else
|
||||
voxtype record stop
|
||||
fi
|
||||
;;
|
||||
*) echo "Usage: omarchy dictation voxtype <start|stop|toggle>" >&2; exit 1 ;;
|
||||
esac
|
||||
@@ -129,8 +129,7 @@ term_pt_for() {
|
||||
'BEGIN { printf "%d", int(s * p / b + 0.5) }'
|
||||
}
|
||||
|
||||
# Set the font point size in terminal configs, creating Kitty overrides when
|
||||
# it inherits its size from the system config. Family is left
|
||||
# Set the font point size in every terminal config that exists. Family is left
|
||||
# untouched — that is omarchy-font-set's job. Live-reload signals mirror
|
||||
# omarchy-font-set; foot has no reload signal, so running instances are nudged.
|
||||
set_terminal_size() {
|
||||
@@ -140,13 +139,8 @@ set_terminal_size() {
|
||||
sed -i -E "s/^size[[:space:]]*=.*/size = $pt/" ~/.config/alacritty/alacritty.toml
|
||||
fi
|
||||
|
||||
if [[ -f ~/.config/kitty/kitty.conf ]] || omarchy-cmd-present kitty; then
|
||||
mkdir -p ~/.config/kitty
|
||||
if grep -qE '^[[:space:]]*font_size[[:space:]]+' ~/.config/kitty/kitty.conf 2>/dev/null; then
|
||||
sed --follow-symlinks -i -E "s/^[[:space:]]*font_size[[:space:]]+.*/font_size $pt.0/" ~/.config/kitty/kitty.conf
|
||||
else
|
||||
printf '\nfont_size %s.0\n' "$pt" >>~/.config/kitty/kitty.conf
|
||||
fi
|
||||
if [[ -f ~/.config/kitty/kitty.conf ]]; then
|
||||
sed -i -E "s/^font_size[[:space:]]+.*/font_size $pt.0/" ~/.config/kitty/kitty.conf
|
||||
pkill -USR1 kitty 2>/dev/null || true
|
||||
fi
|
||||
|
||||
@@ -169,7 +163,7 @@ set_terminal_size() {
|
||||
[[ $prev_id =~ ^[0-9]+$ ]] && replace=(-r "$prev_id")
|
||||
local new_id
|
||||
new_id="$(omarchy-notification-send \
|
||||
"$(omarchy-text "Restart Foot to apply the new terminal font size")" \
|
||||
"Restart Foot to apply the new terminal font size" \
|
||||
"${replace[@]}" -p 2>/dev/null)" || true
|
||||
[[ $new_id =~ ^[0-9]+$ ]] && printf '%s\n' "$new_id" >"$id_file"
|
||||
fi
|
||||
@@ -183,13 +177,9 @@ term_current_pt() {
|
||||
elif [[ -f ~/.config/alacritty/alacritty.toml ]]; then
|
||||
grep -oP '^size[[:space:]]*=[[:space:]]*\K[0-9.]+' ~/.config/alacritty/alacritty.toml | head -1
|
||||
elif [[ -f ~/.config/kitty/kitty.conf ]]; then
|
||||
local pt
|
||||
pt=$(grep -oP '^[[:space:]]*font_size[[:space:]]+\K[0-9.]+' ~/.config/kitty/kitty.conf | tail -1)
|
||||
echo "${pt:-$TERM_DEFAULT_PT}"
|
||||
grep -oP '^font_size[[:space:]]+\K[0-9.]+' ~/.config/kitty/kitty.conf | head -1
|
||||
elif [[ -f ~/.config/foot/foot.ini ]]; then
|
||||
grep -oP ':size=\K[0-9.]+' ~/.config/foot/foot.ini | head -1
|
||||
elif omarchy-cmd-present kitty; then
|
||||
echo "$TERM_DEFAULT_PT"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
+19
-419
@@ -1,432 +1,32 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Set a new password for an encrypted drive
|
||||
# omarchy:summary=Set a new encryption password for a drive selected.
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
# Changes a drive's LUKS key and nothing else. For the disk holding /, the new
|
||||
# key must open the disk and the old one must not before the change counts. The
|
||||
# journal records only the disk's UUID, key slots and phase, so a change that
|
||||
# stops part way is finished by the next run. A platform whose boot checks prove
|
||||
# the disk's key slots records the owner's new one before the run finishes.
|
||||
|
||||
# Passwords live in variables; never trace them.
|
||||
set +x
|
||||
|
||||
REKEY_STATE=${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/drive-password.state
|
||||
source "$OMARCHY_PATH/install/provisioning/luks-rekey.sh"
|
||||
|
||||
# The LUKS partition beneath /, or nothing when / is not encrypted. Fails when
|
||||
# it cannot tell, including a crypt layer whose LUKS partition lsblk cannot name.
|
||||
root_luks_device() {
|
||||
local root_source ancestry device
|
||||
root_source=$(findmnt -no SOURCE /) && [[ -n $root_source ]] || return 1
|
||||
ancestry=$(lsblk -nsrpo NAME,TYPE,FSTYPE "${root_source%%[*}") || return 1
|
||||
device=$(awk '$3 == "crypto_LUKS" { print $1; exit }' <<<"$ancestry")
|
||||
if [[ -n $device ]]; then
|
||||
readlink -f "$device"
|
||||
elif awk '$2 == "crypt" { found = 1 } END { exit !found }' <<<"$ancestry"; then
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Every LUKS device, from udev's probe as root_luks_device reads it. An
|
||||
# unprivileged blkid only reads /run/blkid, empty until root runs blkid. lsblk
|
||||
# repeats a device under each parent (RAID members), so each is listed once.
|
||||
luks_drives() {
|
||||
lsblk -nrpo NAME,FSTYPE | awk '$2 == "crypto_LUKS" && !seen[$1]++ { print $1 }'
|
||||
}
|
||||
|
||||
# Every cryptsetup call holds the disk lock, so one left running by a run that
|
||||
# was killed finishes before the next run looks at the drive.
|
||||
|
||||
# Print the key slot the password opens. Returns 2 when it opens none, and
|
||||
# another failure when the drive cannot be checked. cryptsetup open tries
|
||||
# enrolled tokens (TPM2, FIDO2, keyring) before the key and reports a token's
|
||||
# slot whatever key it was given, so allow only a token type no token has.
|
||||
drive_key_slot() {
|
||||
local drive=$1 password=$2 out status=0
|
||||
out=$(printf '%s' "$password" | sudo flock "$disk_lock" env LC_ALL=C cryptsetup open --test-passphrase --verbose --token-type passphrase-only --key-file - "$drive" 2>&1) || status=$?
|
||||
(( status == 0 )) || return "$status"
|
||||
grep -o 'Key slot [0-9]* unlocked' <<<"$out" | grep -o '[0-9]*' | head -1
|
||||
}
|
||||
|
||||
# The enabled key slots, comma-separated. A luks2-keyring token under "Tokens:"
|
||||
# looks like a keyslot, so read only the keyslot section.
|
||||
drive_slots() {
|
||||
local dump
|
||||
dump=$(sudo flock "$disk_lock" cryptsetup luksDump "$1") || return 1
|
||||
awk '
|
||||
/^[^ \t]/ { keyslots = ($0 == "Keyslots:") }
|
||||
keyslots && /^ +[0-9]+: luks2/ { sub(":", "", $1); print $1 }
|
||||
/^Key Slot [0-9]+: ENABLED/ { sub(":", "", $3); print $3 }' <<<"$dump" | paste -sd, -
|
||||
}
|
||||
|
||||
# The key slots other than FOUND that the password also opens, comma-separated,
|
||||
# or nothing. Fails when a slot cannot be checked.
|
||||
other_key_slots() {
|
||||
local drive=$1 password=$2 found=$3 slots slot status
|
||||
local -a others=()
|
||||
slots=$(drive_slots "$drive") || return 1
|
||||
for slot in ${slots//,/ }; do
|
||||
[[ $slot != "$found" ]] || continue
|
||||
status=0
|
||||
printf '%s' "$password" | sudo flock "$disk_lock" cryptsetup open --test-passphrase --token-type passphrase-only --key-slot "$slot" --key-file - "$drive" >/dev/null 2>&1 || status=$?
|
||||
if (( status == 0 )); then
|
||||
others+=("$slot")
|
||||
elif (( status != 2 )); then
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
local IFS=,
|
||||
printf '%s' "${others[*]}"
|
||||
}
|
||||
|
||||
kill_slot() {
|
||||
local drive=$1 password=$2 slot=$3
|
||||
printf '%s' "$password" | sudo flock "$disk_lock" cryptsetup luksKillSlot -q --key-file - "$drive" "$slot"
|
||||
}
|
||||
|
||||
# luksChangeKey writes the new key before it retires the old one (LUKS2 swaps
|
||||
# them in one header update), so a failure that leaves the new key unable to
|
||||
# open the drive changed nothing: that returns 2. One that stops in between
|
||||
# leaves both keys, so the old key is retired here until it opens nothing.
|
||||
# Sets new_slot.
|
||||
change_luks_key() {
|
||||
local drive=$1 current=$2 new=$3 old_slot=$4 remains attempt status
|
||||
local -a pbkdf=(--iter-time 2000)
|
||||
|
||||
echo "Changing full-disk encryption password for $drive"
|
||||
if sudo flock "$disk_lock" cryptsetup isLuks --type luks2 "$drive"; then
|
||||
pbkdf+=(--pbkdf argon2id)
|
||||
fi
|
||||
# Both keys travel over stdin, the current one on the first line.
|
||||
printf '%s\n%s' "$current" "$new" |
|
||||
sudo flock "$disk_lock" bash -c 'IFS= read -r current; drive=$1; shift; exec cryptsetup luksChangeKey "$@" --key-file <(printf "%s" "$current") "$drive" <(cat)' bash "$drive" "${pbkdf[@]}"
|
||||
|
||||
new_slot=$(drive_key_slot "$drive" "$new") || return
|
||||
for (( attempt = 0; attempt < 8; attempt++ )); do
|
||||
status=0
|
||||
remains=$(drive_key_slot "$drive" "$current") || status=$?
|
||||
(( status == 2 )) && return 0
|
||||
(( status == 0 )) && [[ $remains != "$new_slot" ]] || return 1
|
||||
kill_slot "$drive" "$new" "$remains" || return 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# The entrypoint that records the system disk's key slots for the platform's
|
||||
# boot checks, or nothing where none does. A machine without its platform's
|
||||
# boot package at all (dispatch exit 3) predates it, and nothing checks its
|
||||
# slots. Fails with the dispatcher's message otherwise, such as a boot package
|
||||
# too old to ship luks-slots.
|
||||
slot_recorder() {
|
||||
local out status=0
|
||||
out=$("$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" --resolve luks-slots 2>&1) || status=$?
|
||||
if (( status == 0 )); then
|
||||
printf '%s' "$out"
|
||||
elif (( status != 3 )); then
|
||||
printf '%s\n' "$out" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Refuse a current password that opens any slot but the owner's where the
|
||||
# platform records the slots: on a disk an earlier setup gave a second key (a
|
||||
# recovery key), changing that one would leave the owner's password as it was.
|
||||
# luks-slots --owner prints the recorded slot (0), or exits 4 when no owner slot
|
||||
# is recorded, as on a Mac set up before its boot package recorded slots; any
|
||||
# other status is a failure the entrypoint explains, and stops the change.
|
||||
# Where nothing is recorded, a password that opens the disk's only key slot is
|
||||
# the owner's, and the change records its new slot (unrecorded_owner).
|
||||
owner_slot_matches() {
|
||||
local slot=$1 recorder owner errors reason status=0
|
||||
recorder=$(slot_recorder) || return 1
|
||||
[[ -n $recorder ]] || return 0
|
||||
errors=$(mktemp) || return 1
|
||||
owner=$(sudo "$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" luks-slots --owner 2>"$errors") || status=$?
|
||||
reason=$(<"$errors")
|
||||
rm -f "$errors"
|
||||
if (( status == 4 )); then
|
||||
only_key_slot "$slot" || return 1
|
||||
unrecorded_owner=1
|
||||
return 0
|
||||
elif (( status != 0 )); then
|
||||
echo "Could not read which key slot holds the system disk password: ${reason:-luks-slots --owner exited $status}"
|
||||
return 1
|
||||
elif [[ ! $owner =~ ^[0-9]+$ ]]; then
|
||||
echo "Could not read which key slot holds the system disk password: the platform's boot package named '$owner', which is not a key slot."
|
||||
return 1
|
||||
fi
|
||||
if [[ $owner != "$slot" ]]; then
|
||||
echo "That password opens another key slot on the system disk, such as a recovery key from an earlier setup. Enter the system disk password."
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# With no owner slot recorded, the password must open the system disk's one key
|
||||
# slot: with more keys there is no telling which is the owner's, and recording
|
||||
# only the owner's would fail the platform's boot check, which wants every kept
|
||||
# slot recorded. Says how to settle it.
|
||||
only_key_slot() {
|
||||
local slot=$1 slots others
|
||||
slots=$(drive_slots "$root_drive") || { echo "Could not read the key slots of $root_drive."; return 1; }
|
||||
[[ $slots != "$slot" ]] || return 0
|
||||
others=$(tr ',' '\n' <<<"$slots" | grep -vx -- "$slot" | paste -sd, -)
|
||||
echo "No key slot is recorded as the system disk password's, and $root_drive has keys in slots $slots, so which one is yours can't be told. The password you entered opens slot $slot."
|
||||
if [[ $others =~ ^[0-9]+$ ]]; then
|
||||
echo "If that password is yours and slot $others holds a recovery key you keep, record both: sudo omarchy-lifecycle-dispatch luks-slots owner=$slot recovery=$others"
|
||||
echo "Otherwise remove the key you don't use: sudo cryptsetup luksKillSlot $root_drive <slot>"
|
||||
else
|
||||
echo "Remove the keys you don't use: sudo cryptsetup luksKillSlot $root_drive <slot>"
|
||||
fi
|
||||
echo "Then run omarchy-drive-password again."
|
||||
return 1
|
||||
}
|
||||
|
||||
# Record the slot the owner's password opens now; a no-op where nothing does.
|
||||
record_owner_slot() {
|
||||
local recorder
|
||||
recorder=$(slot_recorder) || return 1
|
||||
[[ -z $recorder ]] || sudo "$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" luks-slots "owner=$1"
|
||||
}
|
||||
|
||||
# Phase luks: the key change may or may not have landed. Phase record: the new
|
||||
# key alone opens the recorded slot, which the boot checks still need.
|
||||
change_system_password() {
|
||||
local current=$1 new=$2 old_slot=$3 slots status=0
|
||||
|
||||
slots=$(drive_slots "$root_drive")
|
||||
if [[ ,$slots, != *",$old_slot,"* ]]; then
|
||||
echo "Could not read the key slots of $root_drive."
|
||||
return 1
|
||||
fi
|
||||
# A disk taken as the owner's for holding one key must still hold only it.
|
||||
if (( unrecorded_owner )) && [[ $slots != "$old_slot" ]]; then
|
||||
echo "The key slots of $root_drive changed while you entered the new password. Run omarchy-drive-password again."
|
||||
return 1
|
||||
fi
|
||||
mkdir -p "${REKEY_STATE%/*}" &&
|
||||
rekey_state_put uuid "$root_uuid" old_slot "$old_slot" slots "$slots" checked 1 phase luks || return 1
|
||||
|
||||
change_luks_key "$root_drive" "$current" "$new" "$old_slot" || status=$?
|
||||
if (( status == 2 )); then
|
||||
rm -f "$REKEY_STATE"
|
||||
echo "The disk password did not change."
|
||||
return 1
|
||||
elif (( status != 0 )); then
|
||||
echo "Could not confirm the disk password change. Run omarchy-drive-password again to finish."
|
||||
return 1
|
||||
fi
|
||||
rekey_state_put slot "$new_slot" phase record || return 1
|
||||
|
||||
if ! record_owner_slot "$new_slot"; then
|
||||
echo "Could not record the new key slot for the boot checks. Run omarchy-drive-password again to finish."
|
||||
return 1
|
||||
fi
|
||||
rm -f "$REKEY_STATE"
|
||||
}
|
||||
|
||||
# A journal left on this system's disk by a change that stopped part way.
|
||||
change_pending() {
|
||||
local phase uuid
|
||||
phase=$(rekey_state_get phase) || return 1
|
||||
uuid=$(rekey_state_get uuid)
|
||||
if [[ $phase == "luks" || $phase == "record" || $phase == "accounts" ]] && [[ -n $uuid && $uuid == "$root_uuid" ]]; then
|
||||
return 0
|
||||
fi
|
||||
rm -f "$REKEY_STATE"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Settle the disk on the password it opens with now, then record its slot.
|
||||
# Before the change was confirmed that is the old key (removing the slot the
|
||||
# change added, if any) or the new key in the one slot the change added
|
||||
# (retiring the old slot); after, only the confirmed slot. Any other key on the
|
||||
# disk, such as a recovery passphrase, is refused, and so is a disk that gained
|
||||
# more than one slot since.
|
||||
finish_interrupted_change() {
|
||||
local phase old_slot slots slot header password opened now added extra count=0 status=0
|
||||
|
||||
phase=$(rekey_state_get phase)
|
||||
# Earlier versions named the confirmed phase "accounts".
|
||||
[[ $phase != "accounts" ]] || phase=record
|
||||
old_slot=$(rekey_state_get old_slot)
|
||||
slots=$(rekey_state_get slots)
|
||||
slot=$(rekey_state_get slot)
|
||||
|
||||
if [[ $phase == "luks" && ! ( $old_slot =~ ^[0-9]+$ && ,$slots, == *",$old_slot,"* ) ]] ||
|
||||
[[ $phase == "record" && ! $slot =~ ^[0-9]+$ ]]; then
|
||||
echo "$REKEY_STATE is unreadable. Remove it once the disk opens with only the password you want."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Earlier versions journaled the change without checking that the old
|
||||
# password opened one slot alone (checked=1), so with other slots on the disk
|
||||
# a copy of it may have outlived the change. No password entered now can show
|
||||
# which slot was whose, so the owner settles that by hand.
|
||||
if [[ -z $(rekey_state_get checked) ]]; then
|
||||
now=$(drive_slots "$root_drive") || { echo "Could not read the key slots of $root_drive."; return 1; }
|
||||
if [[ $now == *,* ]]; then
|
||||
echo "The last password change for $root_drive was started by an earlier version of omarchy-drive-password, and the disk has key slots $now, so it may still hold a copy of the old password."
|
||||
echo "Settle it by hand, in this order:"
|
||||
echo "1. Find the slot the password you want to keep opens, and keep it: sudo cryptsetup open --test-passphrase --token-type passphrase-only --verbose $root_drive names it. If only the old password opens the disk, that is the one you keep."
|
||||
echo "2. If the password you keep is not the old one, check each other slot with the old password (sudo cryptsetup open --test-passphrase --token-type passphrase-only --key-slot <slot> $root_drive) and remove each one it opens (sudo cryptsetup luksKillSlot $root_drive <slot>). Never remove the slot from step 1."
|
||||
if [[ -n $(slot_recorder 2>/dev/null) ]]; then
|
||||
echo "3. Record the slots you kept for the boot checks: sudo omarchy-lifecycle-dispatch luks-slots owner=<slot> recovery=<slot>, with an empty recovery= when no recovery key is left."
|
||||
fi
|
||||
echo "Then remove $REKEY_STATE, and run omarchy-drive-password again if the disk should still take a new password."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "The last password change for $root_drive stopped part way."
|
||||
if [[ $phase == "record" ]]; then
|
||||
header="New encryption password"
|
||||
else
|
||||
header="Encryption password that opens $root_drive now (the new one if the change went through)"
|
||||
fi
|
||||
password=$(gum input --password --header "$header") || return 1
|
||||
|
||||
opened=$(drive_key_slot "$root_drive" "$password") || status=$?
|
||||
if (( status != 0 && status != 2 )); then
|
||||
echo "Could not check the password against $root_drive."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ $phase == "luks" && -n $opened ]]; then
|
||||
now=$(drive_slots "$root_drive") || { echo "Could not read the key slots of $root_drive."; return 1; }
|
||||
for extra in ${now//,/ }; do
|
||||
if [[ ,$slots, != *",$extra,"* ]]; then
|
||||
added=$extra
|
||||
count=$(( count + 1 ))
|
||||
fi
|
||||
done
|
||||
if (( count > 1 )); then
|
||||
echo "The key slots of $root_drive changed since the interrupted change. Finish it by hand."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ $opened == "$old_slot" ]]; then
|
||||
[[ -z $added ]] || kill_slot "$root_drive" "$password" "$added" || return 1
|
||||
elif [[ -n $added && $opened == "$added" ]]; then
|
||||
if [[ ,$now, == *",$old_slot,"* ]]; then
|
||||
kill_slot "$root_drive" "$password" "$old_slot" || return 1
|
||||
fi
|
||||
else
|
||||
opened=""
|
||||
fi
|
||||
[[ -z $opened ]] || rekey_state_put slot "$opened" phase record || return 1
|
||||
elif [[ $phase == "record" && $opened != "$slot" ]]; then
|
||||
opened=""
|
||||
fi
|
||||
|
||||
if [[ -z $opened ]]; then
|
||||
echo "That password does not finish the change on $root_drive. Run omarchy-drive-password again to finish."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! record_owner_slot "$opened"; then
|
||||
echo "Could not record the key slot for the boot checks. Run omarchy-drive-password again to finish."
|
||||
return 1
|
||||
fi
|
||||
rm -f "$REKEY_STATE"
|
||||
echo "$root_drive now opens with the password you entered, and the change is settled."
|
||||
}
|
||||
|
||||
change_selected_drive() {
|
||||
local encrypted_drives drive current old_slot others new_password confirmation status=0 system_drive=false
|
||||
|
||||
encrypted_drives=$(luks_drives)
|
||||
if [[ -z $encrypted_drives ]]; then
|
||||
echo "No encrypted drives available."
|
||||
return 1
|
||||
fi
|
||||
encrypted_drives=$(blkid -t TYPE=crypto_LUKS -o device)
|
||||
|
||||
if [[ -n $encrypted_drives ]]; then
|
||||
if (( $(wc -l <<<"$encrypted_drives") == 1 )); then
|
||||
drive="$encrypted_drives"
|
||||
drive_to_change="$encrypted_drives"
|
||||
else
|
||||
drive="$(omarchy-drive-select "$encrypted_drives")"
|
||||
drive_to_change="$(omarchy-drive-select "$encrypted_drives")"
|
||||
fi
|
||||
|
||||
if [[ -z $drive ]]; then
|
||||
if [[ -n $drive_to_change ]]; then
|
||||
new_password=$(gum input --password --header "New encryption password") || exit 1
|
||||
[[ -n $new_password ]] || { echo "Password cannot be empty."; exit 1; }
|
||||
|
||||
confirmation=$(gum input --password --header "Confirm new encryption password") || exit 1
|
||||
[[ $new_password == "$confirmation" ]] || { echo "Passwords do not match."; exit 1; }
|
||||
|
||||
echo "Changing full-disk encryption password for $drive_to_change"
|
||||
# The new key travels over stdin and reaches cryptsetup as a keyfile via
|
||||
# <(cat), leaving the tty free for the current-passphrase prompt.
|
||||
printf "%s" "$new_password" | sudo bash -c 'exec cryptsetup luksChangeKey --pbkdf argon2id --iter-time 2000 "$1" <(cat) </dev/tty' bash "$drive_to_change"
|
||||
else
|
||||
echo "No drive selected."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -n $root_drive && $(readlink -f "$drive") == "$root_drive" ]]; then
|
||||
system_drive=true
|
||||
drive=$root_drive
|
||||
fi
|
||||
|
||||
current=$(gum input --password --header "Current encryption password") || return 1
|
||||
old_slot=$(drive_key_slot "$drive" "$current") || status=$?
|
||||
if (( status == 2 )); then
|
||||
echo "That password does not open $drive."
|
||||
return 1
|
||||
elif (( status != 0 )); then
|
||||
echo "Could not check the password against $drive."
|
||||
return 1
|
||||
fi
|
||||
if [[ $system_drive == "true" ]]; then
|
||||
owner_slot_matches "$old_slot" || { echo "The system disk password did not change."; return 1; }
|
||||
# An interrupted change is finished from the one slot the password opened;
|
||||
# another copy of it would outlive the change.
|
||||
others=$(other_key_slots "$drive" "$current" "$old_slot") || { echo "Could not check the key slots of $drive."; return 1; }
|
||||
if [[ -n $others ]]; then
|
||||
echo "That password also opens key slot $others on $drive. Remove the extra copy first (sudo cryptsetup luksKillSlot $drive <slot>), then run omarchy-drive-password again."
|
||||
if [[ -n $(slot_recorder 2>/dev/null) ]]; then
|
||||
echo "Then record the slots you keep for the boot checks: sudo omarchy-lifecycle-dispatch luks-slots owner=<slot> recovery=<slot>, with an empty recovery= when no recovery key is left."
|
||||
fi
|
||||
echo "The system disk password did not change."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
new_password=$(gum input --password --header "New encryption password") || return 1
|
||||
[[ -n $new_password ]] || { echo "Password cannot be empty."; return 1; }
|
||||
|
||||
confirmation=$(gum input --password --header "Confirm new encryption password") || return 1
|
||||
[[ $new_password == "$confirmation" ]] || { echo "Passwords do not match."; return 1; }
|
||||
[[ $new_password != "$current" ]] || { echo "The new password is the current one."; return 1; }
|
||||
# Its slot is how the change is followed, so it must not open one already.
|
||||
status=0
|
||||
drive_key_slot "$drive" "$new_password" >/dev/null || status=$?
|
||||
if (( status == 0 )); then
|
||||
echo "That password already opens a key slot on $drive. Choose another."
|
||||
return 1
|
||||
elif (( status != 2 )); then
|
||||
echo "Could not check the password against $drive."
|
||||
return 1
|
||||
fi
|
||||
status=0
|
||||
|
||||
if [[ $system_drive == "true" ]]; then
|
||||
change_system_password "$current" "$new_password" "$old_slot"
|
||||
else
|
||||
change_luks_key "$drive" "$current" "$new_password" "$old_slot" || status=$?
|
||||
if (( status == 2 )); then
|
||||
echo "The password of $drive did not change."
|
||||
return 1
|
||||
elif (( status != 0 )); then
|
||||
echo "Could not confirm the password change on $drive."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
disk_lock=${REKEY_STATE%/*}/drive-password.disk.lock
|
||||
mkdir -p "${REKEY_STATE%/*}" && touch "$disk_lock" && exec {lock_fd}>"${REKEY_STATE%/*}/drive-password.lock" && flock -n "$lock_fd" ||
|
||||
{ echo "Another drive password change is running."; exit 1; }
|
||||
|
||||
unrecorded_owner=0
|
||||
root_drive=$(root_luks_device) || { echo "Could not tell which drive holds the system."; exit 1; }
|
||||
root_uuid=""
|
||||
if [[ -n $root_drive ]]; then
|
||||
root_uuid=$(lsblk -dno UUID "$root_drive") && [[ -n $root_uuid ]] || { echo "Could not read the UUID of $root_drive."; exit 1; }
|
||||
fi
|
||||
|
||||
if change_pending; then
|
||||
finish_interrupted_change
|
||||
else
|
||||
change_selected_drive
|
||||
echo "No encrypted drives available."
|
||||
exit 1
|
||||
fi
|
||||
+4
-10
@@ -30,14 +30,8 @@ if [[ -f ~/.config/alacritty/alacritty.toml ]]; then
|
||||
sed -i "s/family = \".*\"/family = \"$font_name\"/g" ~/.config/alacritty/alacritty.toml
|
||||
fi
|
||||
|
||||
if [[ -f ~/.config/kitty/kitty.conf ]] || omarchy-cmd-present kitty; then
|
||||
mkdir -p ~/.config/kitty
|
||||
if grep -qE '^[[:space:]]*font_family[[:space:]]+' ~/.config/kitty/kitty.conf 2>/dev/null; then
|
||||
kitty_font_name=$(printf '%s' "$font_name" | sed 's/[\\&/]/\\&/g')
|
||||
sed --follow-symlinks -i -E "s/^[[:space:]]*font_family[[:space:]]+.*/font_family $kitty_font_name/" ~/.config/kitty/kitty.conf
|
||||
else
|
||||
printf '\nfont_family %s\n' "$font_name" >>~/.config/kitty/kitty.conf
|
||||
fi
|
||||
if [[ -f ~/.config/kitty/kitty.conf ]]; then
|
||||
sed -i "s/^font_family .*/font_family $font_name/g" ~/.config/kitty/kitty.conf
|
||||
pkill -USR1 kitty
|
||||
fi
|
||||
|
||||
@@ -74,11 +68,11 @@ XML
|
||||
omarchy-restart-shell
|
||||
|
||||
if pgrep -x ghostty; then
|
||||
omarchy-notification-send -g "$(omarchy-text "You must restart Ghostty to see font change")"
|
||||
omarchy-notification-send -g "You must restart Ghostty to see font change"
|
||||
fi
|
||||
|
||||
if pgrep -x foot; then
|
||||
omarchy-notification-send -g "$(omarchy-text "You must restart Foot to see font change")"
|
||||
omarchy-notification-send -g "You must restart Foot to see font change"
|
||||
fi
|
||||
|
||||
omarchy-hook font-set "$font_name"
|
||||
@@ -10,7 +10,7 @@ if (( $# == 0 )); then
|
||||
mapfile -t cores < <(omarchy-games-retro-cores)
|
||||
|
||||
if (( ${#cores[@]} == 0 )); then
|
||||
omarchy-notification-send -g "$(omarchy-text "No RetroArch cores found")" "/usr/lib/libretro"
|
||||
omarchy-notification-send -g "No RetroArch cores found" "/usr/lib/libretro"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -69,4 +69,4 @@ EOF
|
||||
chmod +x "$desktop_file"
|
||||
update-desktop-database "$desktop_dir" &>/dev/null || true
|
||||
|
||||
omarchy-notification-send -g "$(omarchy-text "%1 installed" "$game_name")" "$(omarchy-text "Start it with Super + Space")"
|
||||
omarchy-notification-send -g "$game_name installed" "Start it with Super + Space"
|
||||
@@ -13,12 +13,6 @@ for arg in "$@"; do
|
||||
esac
|
||||
done
|
||||
|
||||
# Resume is Limine-oriented and hibernate is unreliable on Asahi.
|
||||
if omarchy-hw-aarch64-apple; then
|
||||
echo "Skipping hibernation setup (not supported on Apple Silicon)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ! -f /sys/power/image_size ]]; then
|
||||
echo -e "Hibernation is not supported on your system" >&2
|
||||
exit 0
|
||||
@@ -36,35 +30,6 @@ MKINITCPIO_CONF="/etc/mkinitcpio.conf.d/omarchy_resume.conf"
|
||||
SWAP_FILE="/swap/swapfile"
|
||||
RESUME_DROP_IN="/etc/limine-entry-tool.d/resume.conf"
|
||||
|
||||
install_root_file() {
|
||||
local source="$1"
|
||||
local destination="$2"
|
||||
local mode="$3"
|
||||
local stage
|
||||
|
||||
stage=$(sudo /usr/bin/mktemp -- "${destination%/*}/.${destination##*/}.omarchy.XXXXXX") || return 1
|
||||
safe_stage_path "$stage" "$destination" || return 1
|
||||
|
||||
if sudo /usr/bin/install -m "$mode" -o root -g root -T "$source" "$stage" &&
|
||||
sudo /usr/bin/mv -Tf -- "$stage" "$destination"; then
|
||||
return 0
|
||||
else
|
||||
safe_stage_path "$stage" "$destination" && sudo /usr/bin/rm -f -- "$stage"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
safe_stage_path() {
|
||||
local stage="$1"
|
||||
local destination="$2"
|
||||
local prefix suffix
|
||||
|
||||
prefix="${destination%/*}/.${destination##*/}.omarchy."
|
||||
[[ $stage == "$prefix"* ]] || return 1
|
||||
suffix=${stage#"$prefix"}
|
||||
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
|
||||
}
|
||||
|
||||
# Check if hibernation is already configured
|
||||
if [[ -f $MKINITCPIO_CONF ]] && grep -q "^HOOKS+=(resume)$" "$MKINITCPIO_CONF"; then
|
||||
# Fix empty resume_offset if btrfs map-swapfile failed during initial setup
|
||||
@@ -118,20 +83,14 @@ if ! swapon --show | grep -q "$SWAP_FILE"; then
|
||||
sudo swapon -p 0 "$SWAP_FILE"
|
||||
fi
|
||||
|
||||
# Ensure keyboard backlight doesn't prevent sleep
|
||||
# Install this before writing the resume marker so a failed install remains
|
||||
# retryable through the normal setup command.
|
||||
if ! install_root_file "$OMARCHY_PATH/default/systemd/system-sleep/keyboard-backlight" \
|
||||
/usr/lib/systemd/system-sleep/keyboard-backlight 0755; then
|
||||
echo "Could not install the keyboard-backlight system-sleep hook" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Add resume hook to mkinitcpio
|
||||
sudo mkdir -p /etc/mkinitcpio.conf.d
|
||||
echo "Adding resume hook to $MKINITCPIO_CONF"
|
||||
echo "HOOKS+=(resume)" | sudo tee "$MKINITCPIO_CONF" >/dev/null
|
||||
|
||||
# Ensure keyboard backlight doesn't prevent sleep
|
||||
sudo cp -p "$OMARCHY_PATH/default/systemd/system-sleep/keyboard-backlight" /usr/lib/systemd/system-sleep/
|
||||
|
||||
# Add resume= kernel parameters so the initramfs resume hook knows where to find the
|
||||
# hibernation image. Without these, resume happens late (after GPU drivers load) and fails.
|
||||
if [[ ! -f $RESUME_DROP_IN ]]; then
|
||||
|
||||
@@ -11,18 +11,6 @@ if (( $# < 1 )); then
|
||||
fi
|
||||
|
||||
HOOK=$1
|
||||
|
||||
# Hook names are fixed labels chosen by Omarchy code (post-update, theme-set,
|
||||
# font-set). The name becomes a filename under the hooks directory. A slash
|
||||
# would turn it into directory levels, and a bare `.` or `..` would point bash
|
||||
# at the directory itself or its parent. Refuse those rather than follow them.
|
||||
# Dots inside a name (a..b) are fine; once slashes are out, only the whole
|
||||
# name being `.` or `..` can leave the directory.
|
||||
if [[ -z $HOOK || $HOOK == */* || $HOOK == "." || $HOOK == ".." ]]; then
|
||||
echo "Invalid hook name: $HOOK" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
HOOK_PATH="$HOME/.config/omarchy/hooks/$1"
|
||||
HOOK_DIR="$HOOK_PATH.d"
|
||||
shift
|
||||
|
||||
@@ -15,19 +15,8 @@ fi
|
||||
|
||||
HOOK_TYPE=$1
|
||||
HOOK_FILE=$2
|
||||
|
||||
# Hook types are the same labels omarchy-hook runs (post-update, theme-set).
|
||||
# The type becomes a directory under the hooks directory. A slash would turn
|
||||
# it into directory levels. A bare `.` or `..` is a name the runner already
|
||||
# refuses, so installing under it would write a hook nothing can run. Refuse
|
||||
# those rather than mkdir/cp into them. Dots inside a name (a..b) are fine.
|
||||
if [[ -z $HOOK_TYPE || $HOOK_TYPE == */* || $HOOK_TYPE == "." || $HOOK_TYPE == ".." ]]; then
|
||||
echo "Invalid hook name: $HOOK_TYPE" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
HOOK_DIR="$HOME/.config/omarchy/hooks/$HOOK_TYPE.d"
|
||||
HOOK_NAME=$(basename -- "$HOOK_FILE")
|
||||
HOOK_NAME=$(basename "$HOOK_FILE")
|
||||
HOOK_PATH="$HOOK_DIR/$HOOK_NAME"
|
||||
|
||||
if [[ ! -f $HOOK_FILE ]]; then
|
||||
@@ -36,7 +25,7 @@ if [[ ! -f $HOOK_FILE ]]; then
|
||||
fi
|
||||
|
||||
mkdir -p "$HOOK_DIR"
|
||||
cp -- "$HOOK_FILE" "$HOOK_PATH"
|
||||
cp "$HOOK_FILE" "$HOOK_PATH"
|
||||
chmod 755 "$HOOK_PATH"
|
||||
|
||||
echo "Installed $HOOK_TYPE hook: $HOOK_PATH"
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Detect whether the CPU is aarch64 (every ARM machine, Apple Silicon included).
|
||||
|
||||
[[ $(uname -m) == "aarch64" ]]
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/bin/bash -p
|
||||
|
||||
# omarchy:summary=Detect whether the computer is an Apple Silicon Mac.
|
||||
|
||||
# Callers run this by absolute path, from systemd and from the shell, so use the
|
||||
# detector shipped beside it rather than whichever one PATH finds. -p keeps a
|
||||
# root caller's exported functions out, so a launch without it is refused, and
|
||||
# nothing here searches PATH.
|
||||
if [[ $- != *p* ]]; then
|
||||
echo "Refusing an unsafe Bash startup." >&2
|
||||
exit 126
|
||||
fi
|
||||
|
||||
dir=${BASH_SOURCE[0]%/*}
|
||||
[[ $dir != "${BASH_SOURCE[0]}" ]] || dir=.
|
||||
platform=$("$dir/omarchy-hw-platform") || exit 1
|
||||
[[ $platform == "aarch64-apple" ]]
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/bin/bash -p
|
||||
|
||||
# omarchy:summary=Detect whether the computer is an Apple Silicon Mac (the old name of omarchy-hw-aarch64-apple).
|
||||
# omarchy:hidden=true
|
||||
|
||||
# Kept while omarchy-mac's packages and units still call it by this name. Like
|
||||
# the predicate it runs, it refuses a launch without -p and searches no PATH.
|
||||
if [[ $- != *p* ]]; then
|
||||
echo "Refusing an unsafe Bash startup." >&2
|
||||
exit 126
|
||||
fi
|
||||
|
||||
dir=${BASH_SOURCE[0]%/*}
|
||||
[[ $dir != "${BASH_SOURCE[0]}" ]] || dir=.
|
||||
exec "$dir/omarchy-hw-aarch64-apple"
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Match the Dell XPS 13 DX13260 with an Intel Panther Lake GPU.
|
||||
|
||||
omarchy-hw-match "DX13260" && omarchy-hw-intel-ptl
|
||||
+6
-40
@@ -5,48 +5,14 @@
|
||||
|
||||
backlight_path="${OMARCHY_BACKLIGHT_PATH:-/sys/class/backlight}"
|
||||
|
||||
# The platform package may name backlights to skip and to prefer (see
|
||||
# docs/file-layout.md).
|
||||
displays_conf=/usr/share/omarchy-platform/displays.conf
|
||||
skips=()
|
||||
prefers=()
|
||||
if [[ -r $displays_conf ]]; then
|
||||
# \r in IFS: a file saved with CRLF line ends reads the same.
|
||||
while IFS=$' \t\r' read -r directive argument extra || [[ -n $directive ]]; do
|
||||
[[ -n $argument && -z $extra && $argument != */* && $argument != . && $argument != .. ]] || continue
|
||||
case $directive in
|
||||
backlight-skip) skips+=("$argument") ;;
|
||||
backlight-prefer) prefers+=("$argument") ;;
|
||||
esac
|
||||
done <"$displays_conf"
|
||||
fi
|
||||
|
||||
usable() {
|
||||
local skip
|
||||
# The Touch Bar on T2 Macs registers a backlight that never drives the display panel.
|
||||
[[ -n $1 && $1 != appletb_backlight ]] || return 1
|
||||
for skip in "${skips[@]}"; do
|
||||
# shellcheck disable=SC2053 # a skip is a glob
|
||||
[[ $1 == $skip ]] && return 1
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# Start with the first possible output, then refine to the most likely given an order heuristic.
|
||||
device=""
|
||||
while IFS= read -r name; do
|
||||
if usable "$name"; then
|
||||
device="$name"
|
||||
break
|
||||
fi
|
||||
done < <(ls -1 "$backlight_path" 2>/dev/null)
|
||||
|
||||
# gmux comes first: apple-gmux only registers when the kernel has already picked it, and on
|
||||
# dual-GPU Macs the GPU's own PWM stops driving the panel once that GPU suspends. The
|
||||
# platform's preferred backlights follow, then the GPU and ACPI ones.
|
||||
# Glob the candidates in the loop list: [[ ]] does not do pathname expansion.
|
||||
for candidate in "$backlight_path"/gmux_backlight "${prefers[@]/#/$backlight_path/}" "$backlight_path"/amdgpu_bl* "$backlight_path"/intel_backlight "$backlight_path"/acpi_video*; do
|
||||
if [[ -e $candidate ]] && usable "${candidate##*/}"; then
|
||||
# The Touch Bar on T2 Macs registers a backlight that never drives the display panel.
|
||||
device="$(ls -1 "$backlight_path" 2>/dev/null | grep -vx appletb_backlight | head -n1)"
|
||||
# gmux comes first: apple-gmux only registers when the kernel has already picked it, and on
|
||||
# dual-GPU Macs the GPU's own PWM stops driving the panel once that GPU suspends.
|
||||
for candidate in "$backlight_path"/gmux_backlight "$backlight_path"/amdgpu_bl* "$backlight_path"/intel_backlight "$backlight_path"/acpi_video*; do
|
||||
if [[ -e $candidate ]]; then
|
||||
device="${candidate##*/}"
|
||||
break
|
||||
fi
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Detect whether an Elgato Cam Link 4K is plugged in.
|
||||
|
||||
grep -qsx "Cam Link 4K" /sys/bus/usb/devices/*/product
|
||||
+2
-26
@@ -2,39 +2,15 @@
|
||||
|
||||
# omarchy:summary=Returns true when running on a laptop (has a lid or laptop chassis).
|
||||
|
||||
acpi_lid="${OMARCHY_ACPI_LID_PATH:-/proc/acpi/button/lid}"
|
||||
input_class="${OMARCHY_INPUT_CLASS_PATH:-/sys/class/input}"
|
||||
dmi_chassis="${OMARCHY_DMI_CHASSIS_TYPE_PATH:-/sys/class/dmi/id/chassis_type}"
|
||||
|
||||
shopt -s nullglob
|
||||
acpi_states=("$acpi_lid"/*/state)
|
||||
input_switches=("$input_class"/*/capabilities/sw)
|
||||
shopt -u nullglob
|
||||
|
||||
# A lid switch is the definitive signal for clamshell-capable hardware.
|
||||
for state in "${acpi_states[@]}"; do
|
||||
for state in /proc/acpi/button/lid/*/state; do
|
||||
[[ -e $state ]] && exit 0
|
||||
done
|
||||
|
||||
# Without an ACPI lid (Apple Silicon, for one), any input device that reports a
|
||||
# lid switch (SW_LID, bit 0 of its switch capabilities, the last word of the
|
||||
# bitmap) counts. The switch's name varies: Apple's SMC input device carries
|
||||
# the same name on a Mac mini, where it reports no lid.
|
||||
for switches in "${input_switches[@]}"; do
|
||||
[[ -r $switches ]] || continue
|
||||
read -r -a words <"$switches" || true
|
||||
(( ${#words[@]} )) || continue
|
||||
word=${words[-1]}
|
||||
[[ $word =~ ^[0-9a-fA-F]+$ ]] || continue
|
||||
(( 16#$word & 1 )) && exit 0
|
||||
done
|
||||
|
||||
# Fall back to the DMI chassis type for laptops that don't expose an ACPI lid
|
||||
# button. 8=Portable 9=Laptop 10=Notebook 14=Sub Notebook 30=Tablet
|
||||
# 31=Convertible 32=Detachable.
|
||||
chassis_type=""
|
||||
read -r chassis_type <"$dmi_chassis" 2>/dev/null || true
|
||||
case $chassis_type in
|
||||
case $(< /sys/class/dmi/id/chassis_type 2>/dev/null) in
|
||||
8 | 9 | 10 | 14 | 30 | 31 | 32) exit 0 ;;
|
||||
esac
|
||||
|
||||
|
||||
@@ -3,45 +3,9 @@
|
||||
# omarchy:summary=Returns true when the laptop lid is closed
|
||||
# omarchy:hidden=true
|
||||
|
||||
acpi_lid="${OMARCHY_ACPI_LID_PATH:-/proc/acpi/button/lid}"
|
||||
input_class="${OMARCHY_INPUT_CLASS_PATH:-/sys/class/input}"
|
||||
|
||||
shopt -s nullglob
|
||||
acpi_states=("$acpi_lid"/*/state)
|
||||
input_switches=("$input_class"/*/capabilities/sw)
|
||||
shopt -u nullglob
|
||||
|
||||
# An input device that reports a lid switch: SW_LID, bit 0 of the last word
|
||||
# of its switch bitmap.
|
||||
has_lid_switch() {
|
||||
local switches word
|
||||
local -a words
|
||||
|
||||
for switches in "${input_switches[@]}"; do
|
||||
[[ -r $switches ]] || continue
|
||||
read -r -a words <"$switches" || true
|
||||
(( ${#words[@]} )) || continue
|
||||
word=${words[-1]}
|
||||
[[ $word =~ ^[0-9a-fA-F]+$ ]] || continue
|
||||
(( 16#$word & 1 )) && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if (( ${#acpi_states[@]} )); then
|
||||
# An ACPI lid reports its own state.
|
||||
for state in "${acpi_states[@]}"; do
|
||||
[[ -r $state ]] || continue
|
||||
[[ $(<"$state") == *"closed"* ]] && exit 0
|
||||
done
|
||||
elif has_lid_switch; then
|
||||
# Elsewhere (Apple Silicon's SMC, for one) only logind reads the switch. Ask
|
||||
# it only then: it answers "open" when it tracks no lid. This runs in sudo's
|
||||
# and polkit's PAM stack where fingerprint login is set up, so a stalled
|
||||
# logind must not hold up every sudo.
|
||||
lid_state=$(busctl --timeout=2 get-property org.freedesktop.login1 \
|
||||
/org/freedesktop/login1 org.freedesktop.login1.Manager LidClosed 2>/dev/null)
|
||||
[[ $lid_state == "b true" ]] && exit 0
|
||||
fi
|
||||
for state in /proc/acpi/button/lid/*/state; do
|
||||
[[ -r $state ]] || continue
|
||||
[[ $(< "$state") == *"closed"* ]] && exit 0
|
||||
done
|
||||
|
||||
exit 1
|
||||
@@ -3,5 +3,5 @@
|
||||
# omarchy:summary=Match against the computer's DMI product name or product family (case-insensitive).
|
||||
# omarchy:args=<pattern>
|
||||
|
||||
grep -qi -- "$1" /sys/class/dmi/id/product_name 2>/dev/null ||
|
||||
grep -qi -- "$1" /sys/class/dmi/id/product_family 2>/dev/null
|
||||
grep -qi "$1" /sys/class/dmi/id/product_name 2>/dev/null ||
|
||||
grep -qi "$1" /sys/class/dmi/id/product_family 2>/dev/null
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Detect whether NVIDIA drives the display (rather than a hybrid iGPU).
|
||||
|
||||
# On hybrid laptops the display is usually wired to the integrated GPU while
|
||||
# the NVIDIA card only handles offloaded rendering. Forcing NVIDIA's VA-API and
|
||||
# GLX drivers globally then breaks Chromium-based browsers (black video with
|
||||
# audio still playing), so the Hyprland NVIDIA env only applies when NVIDIA is
|
||||
# the display GPU.
|
||||
#
|
||||
# sysfs exposes which VGA device drives the display via boot_vga. When no
|
||||
# device claims it (attribute missing or unreadable), assume NVIDIA is the
|
||||
# display GPU to preserve the current behavior on machines we cannot tell.
|
||||
#
|
||||
# Read the cached sysfs IDs rather than lspci, which reads PCI config space and
|
||||
# resumes runtime-suspended GPUs.
|
||||
pci_devices_path="${OMARCHY_PCI_DEVICES_PATH:-/sys/bus/pci/devices}"
|
||||
|
||||
shopt -s nullglob
|
||||
|
||||
nvidia_boot=0
|
||||
other_boot=0
|
||||
|
||||
for device in "$pci_devices_path"/*; do
|
||||
[[ $(< "$device/class") == 0x03* ]] || continue
|
||||
boot_vga=0
|
||||
if [[ -f $device/boot_vga ]]; then
|
||||
boot_vga=$(< "$device/boot_vga")
|
||||
fi
|
||||
if [[ $(< "$device/vendor") == "0x10de" ]]; then
|
||||
if (( boot_vga == 1 )); then
|
||||
nvidia_boot=1
|
||||
fi
|
||||
else
|
||||
if (( boot_vga == 1 )); then
|
||||
other_boot=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if (( other_boot == 1 && nvidia_boot == 0 )); then
|
||||
exit 1
|
||||
else
|
||||
exit 0
|
||||
fi
|
||||
@@ -1,202 +0,0 @@
|
||||
#!/bin/bash -p
|
||||
|
||||
# omarchy:summary=Print the hardware platform: x86, aarch64 or aarch64-apple.
|
||||
|
||||
# Platform names are the CPU architecture, then the hardware family when it
|
||||
# needs handling of its own: x86, aarch64 (every ARM machine without one) and
|
||||
# aarch64-apple (Apple Silicon). A later family is aarch64-<family>, so
|
||||
# "aarch64*" always means ARM (AGENTS.md, Platforms).
|
||||
#
|
||||
# ARM laptops have no DMI, so the board identity is the root node of the device
|
||||
# tree the firmware hands the kernel: "apple,<model>" from Asahi's m1n1 on every
|
||||
# Apple Silicon Mac, "qcom,<soc>" on Snapdragon laptops, which are plain aarch64
|
||||
# here and must never pass for a Mac. Each compatible string is a NUL-separated
|
||||
# list, and only a token's vendor prefix counts.
|
||||
#
|
||||
# An image is built away from the machine it will run on, so while this root is
|
||||
# being built rather than booted, the image-target manifest the builder wrote
|
||||
# names the platform and the build host's device tree is never read.
|
||||
#
|
||||
# Fixture roots let unprivileged tests fake the device tree, PID 1 and the image
|
||||
# root. Naming any of them makes the whole view a fixture: nothing live is read,
|
||||
# not even the device tree when only the image root is named. A privileged
|
||||
# caller must not let its environment pick the platform at all.
|
||||
# Privileged mode (-p) keeps exported functions and BASH_ENV out of this shell,
|
||||
# so a launch without it is refused. Root then starts over in an empty
|
||||
# environment and reads only the live paths with a fixed PATH.
|
||||
if [[ $- != *p* ]]; then
|
||||
echo "Refusing an unsafe Bash startup." >&2
|
||||
exit 126
|
||||
fi
|
||||
|
||||
if (( EUID == 0 )) && [[ ${1:-} != --clean-environment ]]; then
|
||||
exec /usr/bin/env -i PATH=/usr/bin /bin/bash -p -- "${BASH_SOURCE[0]}" --clean-environment "$@"
|
||||
fi
|
||||
|
||||
if (( EUID == 0 )); then
|
||||
export PATH=/usr/bin
|
||||
fixtures=""
|
||||
else
|
||||
fixtures=${OMARCHY_PROC_ROOT:-}${OMARCHY_SYS_ROOT:-}${OMARCHY_IMAGE_ROOT:-}
|
||||
fi
|
||||
|
||||
if [[ -z $fixtures ]]; then
|
||||
proc=/proc
|
||||
sources=(/proc/device-tree/compatible /sys/firmware/devicetree/base/compatible)
|
||||
image_root=""
|
||||
image_dir=/var/lib/omarchy/image
|
||||
image_owner=0
|
||||
else
|
||||
proc=${OMARCHY_PROC_ROOT:-}
|
||||
sources=()
|
||||
[[ -z ${OMARCHY_PROC_ROOT:-} ]] || sources+=("$OMARCHY_PROC_ROOT/device-tree/compatible")
|
||||
[[ -z ${OMARCHY_SYS_ROOT:-} ]] || sources+=("$OMARCHY_SYS_ROOT/firmware/devicetree/base/compatible")
|
||||
image_root=${OMARCHY_IMAGE_ROOT:-}
|
||||
image_dir=${image_root:+$image_root/var/lib/omarchy/image}
|
||||
image_owner=$EUID
|
||||
fi
|
||||
manifest=$image_dir/target
|
||||
|
||||
contradiction() {
|
||||
echo "Error: contradictory platform identity: $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ! machine=$(uname -m) || [[ -z $machine ]]; then
|
||||
echo "Error: cannot read the CPU architecture" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A booted system is one systemd runs as PID 1 from this root. An image build
|
||||
# is told apart by what it shows: no /run/systemd/system (a root with a /run of
|
||||
# its own), PID 1's root being another (a chroot on the build host, even with
|
||||
# the host's /run bound in, as arch-chroot does), or PID 1 being something other
|
||||
# than systemd (a PID namespace whose PID 1 is the build). What cannot be seen,
|
||||
# such as PID 1's root to a normal user or /proc/1 behind hidepid, never makes a
|
||||
# build: a booted system always answers from its hardware, so a build runs as
|
||||
# root. A caller in a private PID namespace looks like a build too, so nothing
|
||||
# that asks the detector runs with PrivatePIDs=.
|
||||
booted() {
|
||||
local comm
|
||||
|
||||
[[ -d $image_root/run/systemd/system ]] || return 1
|
||||
if [[ -n $proc && -e $proc/1/root ]]; then
|
||||
[[ $proc/1/root -ef $image_root/ ]] || return 1
|
||||
fi
|
||||
if [[ -n $proc && -r $proc/1/comm ]]; then
|
||||
read -r comm <"$proc/1/comm" && [[ $comm == "systemd" ]] || return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Owned by root (the test's own user for a fixture), not a symlink, and not
|
||||
# writable by anyone else.
|
||||
trusted() {
|
||||
local owner mode
|
||||
|
||||
[[ -e $1 && ! -L $1 ]] || return 1
|
||||
read -r owner mode < <(stat -c '%u %a' -- "$1") || return 1
|
||||
[[ $owner =~ ^[0-9]+$ && $mode =~ ^[0-7]+$ ]] && (( owner == image_owner && (8#$mode & 8#022) == 0 ))
|
||||
}
|
||||
|
||||
# The manifest: format=1, platform= one of the platform names, comments and
|
||||
# unknown keys ignored, and any other line refused. Image builders written before
|
||||
# the names settled say apple-silicon, generic-aarch64 or generic, which still
|
||||
# read as aarch64-apple, aarch64 and x86. The detector reads it itself, so it
|
||||
# stands alone wherever a package ships a copy of it.
|
||||
image_platform() {
|
||||
local line key value format="" platform=""
|
||||
|
||||
if [[ ! -d $image_dir || ! -f $manifest ]] || ! trusted "$image_dir" || ! trusted "$manifest"; then
|
||||
echo "Error: the image manifest $manifest is not a root-owned regular file" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
while IFS= read -r line || [[ -n $line ]]; do
|
||||
[[ -n $line && $line != \#* ]] || continue
|
||||
if [[ $line != *=* ]]; then
|
||||
echo "Error: the image manifest $manifest is malformed: $line" >&2
|
||||
return 1
|
||||
fi
|
||||
key=${line%%=*}
|
||||
value=${line#*=}
|
||||
case $key in
|
||||
format) format=$value ;;
|
||||
platform) platform=$value ;;
|
||||
esac
|
||||
done <"$manifest"
|
||||
|
||||
if [[ $format != "1" ]]; then
|
||||
echo "Error: the image manifest $manifest is not format=1" >&2
|
||||
return 1
|
||||
fi
|
||||
case $platform in
|
||||
apple-silicon) platform=aarch64-apple ;;
|
||||
generic-aarch64) platform=aarch64 ;;
|
||||
generic) platform=x86 ;;
|
||||
esac
|
||||
if [[ ! $platform =~ ^(x86|aarch64|aarch64-apple)$ ]]; then
|
||||
echo "Error: the image manifest $manifest names no known platform: ${platform:-none}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$platform"
|
||||
}
|
||||
|
||||
if [[ -n $image_dir ]] && [[ -e $manifest || -L $manifest ]] && ! booted; then
|
||||
platform=$(image_platform) || exit 1
|
||||
if [[ $platform == "x86" ]]; then
|
||||
[[ $machine != "aarch64" ]] || contradiction "$manifest names $platform hardware but the CPU is $machine"
|
||||
else
|
||||
[[ $machine == "aarch64" ]] || contradiction "$manifest names $platform hardware but the CPU is $machine"
|
||||
fi
|
||||
echo "$platform"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
vendor=""
|
||||
vendor_source=""
|
||||
for source in "${sources[@]}"; do
|
||||
[[ -r $source ]] || continue
|
||||
|
||||
apple=0
|
||||
qcom=0
|
||||
mapfile -d '' -t tokens <"$source"
|
||||
for token in "${tokens[@]}"; do
|
||||
[[ $token == "apple,"* ]] && apple=1
|
||||
[[ $token == "qcom,"* ]] && qcom=1
|
||||
done
|
||||
|
||||
if (( apple && qcom )); then
|
||||
contradiction "$source names both Apple and Qualcomm hardware"
|
||||
elif (( apple )); then
|
||||
found=apple
|
||||
elif (( qcom )); then
|
||||
found=qcom
|
||||
else
|
||||
found=none
|
||||
fi
|
||||
|
||||
if [[ -n $vendor && $vendor != "$found" ]]; then
|
||||
contradiction "$vendor_source says $vendor but $source says $found"
|
||||
fi
|
||||
vendor=$found
|
||||
vendor_source=$source
|
||||
done
|
||||
|
||||
case $vendor in
|
||||
apple | qcom)
|
||||
[[ $machine == "aarch64" ]] || contradiction "$vendor_source names $vendor hardware but the CPU is $machine"
|
||||
if [[ $vendor == "apple" ]]; then
|
||||
echo aarch64-apple
|
||||
else
|
||||
echo aarch64
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
if [[ $machine == "aarch64" ]]; then
|
||||
echo aarch64
|
||||
else
|
||||
echo x86
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
+1
-26
@@ -2,30 +2,5 @@
|
||||
|
||||
# omarchy:summary=Print the detected Hyprland touchpad or trackpad device name
|
||||
|
||||
mice=$(hyprctl devices -j | jq -r '.mice[].name')
|
||||
device=$(grep -iE -m1 'touchpad|trackpad' <<<"$mice")
|
||||
|
||||
# Some touchpads carry neither word (Apple Silicon's is "apple-mtp-multi-touch"),
|
||||
# so ask udev which mouse is one. The name alone can't say: some touchscreens'
|
||||
# mouse interfaces are called multi-touch too.
|
||||
if [[ -z $device ]]; then
|
||||
input_class="${OMARCHY_INPUT_CLASS_PATH:-/sys/class/input}"
|
||||
for event in "$input_class"/event*; do
|
||||
[[ -r $event/device/name ]] || continue
|
||||
# Hyprland's name for a device: the kernel's, lowercased, with spaces,
|
||||
# commas and newlines as dashes, and -1, -2 and so on after a name another
|
||||
# device already took.
|
||||
name=$(<"$event/device/name")
|
||||
name=${name,,}
|
||||
name=${name//[ ,$'\n']/-}
|
||||
udevadm info --query=property "$event" 2>/dev/null | grep -Fxq 'ID_INPUT_TOUCHPAD=1' || continue
|
||||
while IFS= read -r mouse; do
|
||||
if [[ $mouse == "$name" || ( $mouse == "$name"-* && ${mouse#"$name"-} =~ ^[0-9]+$ ) ]]; then
|
||||
device=$mouse
|
||||
break 2
|
||||
fi
|
||||
done <<<"$mice"
|
||||
done
|
||||
fi
|
||||
|
||||
device=$(hyprctl devices -j | jq -r '[.mice[] | .name | select(test("touchpad|trackpad"; "i"))] | first // empty')
|
||||
[[ -n $device ]] && echo "$device"
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Returns true when running in a virtual machine.
|
||||
|
||||
systemd-detect-virt --vm --quiet
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Detect whether the CPU is x86_64.
|
||||
|
||||
[[ $(uname -m) == "x86_64" ]]
|
||||
@@ -1,41 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Show or set window corners: theme, sharp, or rounded
|
||||
# omarchy:args=[theme|sharp|rounded]
|
||||
# omarchy:examples=omarchy hyprland corners | omarchy hyprland corners rounded
|
||||
|
||||
TOGGLES_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/toggles/hypr"
|
||||
|
||||
# Sharp and rounded are toggle files loaded after every theme, so they override
|
||||
# the corners of whichever theme is current. Theme removes both.
|
||||
if (( $# == 0 )); then
|
||||
if [[ -f $TOGGLES_DIR/corners-sharp.lua ]]; then
|
||||
echo "sharp"
|
||||
elif [[ -f $TOGGLES_DIR/corners-rounded.lua ]]; then
|
||||
echo "rounded"
|
||||
else
|
||||
echo "theme"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case $1 in
|
||||
theme) LABEL=$(omarchy-text "Corners follow the theme") ;;
|
||||
sharp) LABEL=$(omarchy-text "Sharp corners for all themes") ;;
|
||||
rounded) LABEL=$(omarchy-text "Rounded corners for all themes") ;;
|
||||
*)
|
||||
echo "Usage: omarchy-hyprland-corners [theme|sharp|rounded]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Save the new choice before dropping the old one, so a failed save keeps it.
|
||||
if [[ $1 != "theme" ]]; then
|
||||
mkdir -p "$TOGGLES_DIR" && cp "$OMARCHY_PATH/default/hypr/toggles/corners-$1.lua" "$TOGGLES_DIR/corners-$1.lua" || exit 1
|
||||
fi
|
||||
for mode in sharp rounded; do
|
||||
[[ $mode == "$1" ]] || rm -f "$TOGGLES_DIR/corners-$mode.lua" || exit 1
|
||||
done
|
||||
|
||||
hyprctl reload >/dev/null || exit 1
|
||||
omarchy-notification-send -g "$LABEL"
|
||||
@@ -16,7 +16,7 @@ wake() {
|
||||
on() {
|
||||
if omarchy-hyprland-toggle-enabled $TOGGLE; then
|
||||
omarchy-hyprland-toggle $TOGGLE off
|
||||
omarchy-notification-send -g "$(omarchy-text "Laptop display enabled")"
|
||||
omarchy-notification-send -g "Laptop display enabled"
|
||||
fi
|
||||
|
||||
wake
|
||||
@@ -24,25 +24,25 @@ on() {
|
||||
|
||||
off() {
|
||||
if [[ -z $INTERNAL ]]; then
|
||||
omarchy-notification-send -g "$(omarchy-text "No laptop display found")"
|
||||
omarchy-notification-send -g "No laptop display found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The name is written into generated Lua below, so only a plain connector
|
||||
# name may pass; anything else could execute on the next reload.
|
||||
if [[ ! $INTERNAL =~ ^[A-Za-z0-9._-]+$ ]]; then
|
||||
omarchy-notification-send -g "$(omarchy-text "Refusing unsafe monitor name")"
|
||||
omarchy-notification-send -g "Refusing unsafe monitor name"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! omarchy-hyprland-monitor-external-active; then
|
||||
omarchy-notification-send -g "$(omarchy-text "Can't disable the only active display")"
|
||||
omarchy-notification-send -g "Can't disable the only active display"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if omarchy-hyprland-toggle-disabled $TOGGLE && omarchy-hyprland-toggle-disabled $MIRROR_TOGGLE; then
|
||||
printf 'hl.monitor({ output = "%s", disabled = true })\n' "$INTERNAL" >"$TOGGLE_FLAG"
|
||||
omarchy-notification-send -g "$(omarchy-text "Laptop display disabled")"
|
||||
omarchy-notification-send -g "Laptop display disabled"
|
||||
hyprctl reload
|
||||
fi
|
||||
}
|
||||
|
||||
Loaded 100 of 1150 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user