Compare commits

..
Author SHA1 Message Date
ZacharyZhang-NY 4505badecb Add Dim and Kimi Code to the agent roster and Install > AI
Both install through the lazy mise wrappers (npm:dimcode and
npm:@moonshot-ai/kimi-code), preinstalled on new machines and migrated onto
existing ones unless preinstalls were removed, and each gets a menu entry in
both places: under Setup > Defaults > Agent as a default-agent choice, and
under Install > AI for installing without changing the default. The launch
spellings come from each CLI's actual flags — bare dim is the TUI and `dim
exec` the one-shot, kimi runs full-auto as `kimi --auto` with `--prompt` as
its one-shot. The Install > AI entries are guarded by `mise where`, since the
preinstalled stub would otherwise read as installed. The menu marks are the
Kimi simpleicon and Dim's traced from its app icon, since upstream publishes
no monochrome SVG.
2026-08-31 23:13:42 -04:00
677 changed files with 4739 additions and 45079 deletions

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
# Merges to protected branches need sign-off from an org owner.
* @dhh @ryanrhughes
+1 -1
View File
@@ -1,7 +1,7 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: Suggestion - name: Suggestion
url: https://github.com/omacom/omarchy/discussions/categories/suggestions url: https://github.com/basecamp/omarchy/discussions/categories/suggestions
about: Suggest a new feature, change to existing feature, or other ideas in Discussions. about: Suggest a new feature, change to existing feature, or other ideas in Discussions.
- name: Support - name: Support
url: https://omarchy.org/discord url: https://omarchy.org/discord
-47
View File
@@ -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
View File
@@ -2,5 +2,3 @@
# Python bytecode (orchestrator) # Python bytecode (orchestrator)
__pycache__/ __pycache__/
*.pyc *.pyc
.claude/
+1 -6
View File
@@ -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 `(( ))` 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 - Prefer a full `if`/`else` conditional for simple two-path control flow; don't rely on `exec` or `exit` in one branch to make following statements unreachable
- For strings/paths with spaces, quote them instead of escaping spaces with `\ ` (e.g., `"$APP_DIR/Disk Usage.desktop"`, not `$APP_DIR/Disk\ Usage.desktop`) - For strings/paths with spaces, quote them instead of escaping spaces with `\ ` (e.g., `"$APP_DIR/Disk Usage.desktop"`, not `$APP_DIR/Disk\ Usage.desktop`)
- Shebangs must use `#!/bin/bash` consistently (never `#!/usr/bin/env bash`). A security-sensitive entrypoint may use the exact `#!/bin/bash -p` form only when it must suppress `BASH_ENV` and exported-function startup injection before its first command; that exception must be explained at the boundary and covered by a regression that rejects an ordinary Bash launch with a decoy `-p` argument. - Shebangs must use `#!/bin/bash` consistently (never `#!/usr/bin/env bash`)
- Scripts under `install/` and `migrations/` may be sourced and intentionally omit shebangs - Scripts under `install/` and `migrations/` may be sourced and intentionally omit shebangs
# Command Naming # Command Naming
@@ -96,11 +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 - Do not add `aliases` to new menu entries. Aliases are reserved for
established alternate names users already type, kept for compatibility. established alternate names users already type, kept for compatibility.
# Region
- `/etc/omarchy/region` holds the region the installer derived from the timezone (`cn`); no file means Global. The profile lives in `default/regions/cn/`, its contract in `docs/regions.md`.
- `install/config/region.sh` links the `cn` developer mirrors into `/etc` and `/.cargo` and refuses to replace a regular file it did not create.
# Config Structure # Config Structure
- `config/` - default configs copied to `~/.config/` - `config/` - default configs copied to `~/.config/`
+4 -2
View File
@@ -1,12 +1,14 @@
# Omarchy # Omarchy
Omarchy is a beautiful, fun & agentic Linux distribution by DHH. Omarchy is a beautiful, modern & opinionated Linux distribution by DHH.
Read more at [omarchy.org](https://omarchy.org). Read more at [omarchy.org](https://omarchy.org).
## The Omarchy Manual ## The 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) - [Welcome to Omarchy!](manual/01-welcome-to-omarchy.md)
+7 -3
View File
@@ -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 -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. Then confirm it in the running menu per
[`visual-verification.md`](visual-verification.md). Fontconfig prefers the
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. packaged font over a copy in `~/.local/share/fonts` for the same family, so a
preview needs either the real file replaced or a `<rejectfont>` rule in
`~/.config/fontconfig/conf.d/` pointing fontconfig away from the packaged one.
Restart the shell afterwards — Qt reads the font database at startup, so
`omarchy menu refresh` alone will not pick up a changed font.
-1
View File
@@ -13,7 +13,6 @@ commands and reusable setup leaves:
- use `$OMARCHY_INSTALL` and `$OMARCHY_PATH` instead of hard-coded Omarchy paths. - use `$OMARCHY_INSTALL` and `$OMARCHY_PATH` instead of hard-coded Omarchy paths.
- keep root-scoped hardware setup under `install/hardware/` and orchestrate it through `install/hardware/all.sh`. - keep root-scoped hardware setup under `install/hardware/` and orchestrate it through `install/hardware/all.sh`.
- keep every per-user setup leaf under `install/user/` (including `install/user/hardware/` and `install/user/first-run/`) so it is clear what must run for each user. - 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. - prefer helper commands for package and command checks where available.
Raw `command -v`, `pacman`, and `pacman-key` are acceptable in package-helper Raw `command -v`, `pacman`, and `pacman-key` are acceptable in package-helper
-2
View File
@@ -163,8 +163,6 @@ rm ~/.local/state/omarchy/migrations/<migration>.sh
omarchy-migrate 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 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 normal migration runner. Do not add compatibility migrations for old installer
layouts; put pre-4 package-layout transition work in the upgrade command instead. layouts; put pre-4 package-layout transition work in the upgrade command instead.
+3 -1
View File
@@ -20,7 +20,9 @@ Run `omarchy-restart-shell` after making changes to QML files.
[`docs/omarchy-shell.md`](../../docs/omarchy-shell.md) and [`docs/omarchy-shell.md`](../../docs/omarchy-shell.md) and
`shell/services/PluginRegistry.qml` for the current contract; fields such as `shell/services/PluginRegistry.qml` for the current contract; fields such as
`activation` are optional. `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 - Panel / overlay / menu plugins must expose `open(payloadJson)` and
`close()` lifecycle methods for `shell summon` and `shell hide`. `close()` lifecycle methods for `shell summon` and `shell hide`.
+4 -1
View File
@@ -15,7 +15,10 @@ Take a full-screen screenshot without opening the editor:
omarchy capture screenshot fullscreen save 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 Record a short full-screen video for animation, transition, timing, capture, or
screen-recording changes: screen-recording changes:
File renamed without changes.
+1 -27
View File
@@ -26,7 +26,7 @@ declare -A ROUTE_IS_ALIAS
declare -A BINARY_TO_KEY declare -A BINARY_TO_KEY
declare -A GROUP_DESCRIPTIONS declare -A GROUP_DESCRIPTIONS
GROUP_DESCRIPTIONS[agent]="AI coding agents, their usage, and subscription accounts" GROUP_DESCRIPTIONS[agent]="AI coding agent usage data"
GROUP_DESCRIPTIONS[ascii]="Text drawn as ASCII art" GROUP_DESCRIPTIONS[ascii]="Text drawn as ASCII art"
GROUP_DESCRIPTIONS[audio]="Audio input and output controls" GROUP_DESCRIPTIONS[audio]="Audio input and output controls"
GROUP_DESCRIPTIONS[bar]="Omarchy shell bar layout and settings" GROUP_DESCRIPTIONS[bar]="Omarchy shell bar layout and settings"
@@ -65,7 +65,6 @@ GROUP_DESCRIPTIONS[monitor]="Monitor status helpers"
GROUP_DESCRIPTIONS[network]="Network status helpers" GROUP_DESCRIPTIONS[network]="Network status helpers"
GROUP_DESCRIPTIONS[notification]="Notification helpers" GROUP_DESCRIPTIONS[notification]="Notification helpers"
GROUP_DESCRIPTIONS[mise]="Mise tool wrappers" GROUP_DESCRIPTIONS[mise]="Mise tool wrappers"
GROUP_DESCRIPTIONS[openclaw]="OpenClaw agent platform setup"
GROUP_DESCRIPTIONS[osd]="On-screen display status helpers" GROUP_DESCRIPTIONS[osd]="On-screen display status helpers"
GROUP_DESCRIPTIONS[pkg]="Package management helpers" GROUP_DESCRIPTIONS[pkg]="Package management helpers"
GROUP_DESCRIPTIONS[plugin]="Omarchy shell plugin and bar widget management" GROUP_DESCRIPTIONS[plugin]="Omarchy shell plugin and bar widget management"
@@ -371,20 +370,6 @@ command_requires_args() {
[[ -n $required ]] [[ -n $required ]]
} }
# A command whose documented arguments are all flags can't take a word, so a
# word after it names something else: `omarchy agent account` is the account
# commands, not an argument to omarchy-agent.
command_takes_only_flags() {
local key="$1"
local args="${COMMAND_ARGS[$key]}"
local token=""
[[ -n $args ]] || return 1
for token in $args; do
[[ $token == -* || $token == "[-"* ]] || return 1
done
}
load_group_commands() { load_group_commands() {
local group="$1" local group="$1"
local file="" local file=""
@@ -970,17 +955,6 @@ dispatch_fast_or_help() {
remaining=("${args[@]:DIRECT_RESOLVED_COUNT}") remaining=("${args[@]:DIRECT_RESOLVED_COUNT}")
binary_path="$OMARCHY_BIN_DIR/$DIRECT_RESOLVED_BINARY" binary_path="$OMARCHY_BIN_DIR/$DIRECT_RESOLVED_BINARY"
if (( ${#remaining[@]} == 1 || ${#remaining[@]} == 2 )) && [[ ${remaining[0]} != -* ]] &&
{ (( ${#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 remaining_has_help_flag "${remaining[@]}"; then
if ! load_command_by_binary "$DIRECT_RESOLVED_BINARY"; then if ! load_command_by_binary "$DIRECT_RESOLVED_BINARY"; then
echo "Binary is missing or not executable: $DIRECT_RESOLVED_BINARY" >&2 echo "Binary is missing or not executable: $DIRECT_RESOLVED_BINARY" >&2
+14 -52
View File
@@ -52,7 +52,8 @@ if omarchy-cmd-missing "$agent"; then
fi fi
# Agents launched from the keybinding or menu run unattended, so each one starts # 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 case "$agent" in
opencode) opencode)
command=(opencode --auto) command=(opencode --auto)
@@ -78,43 +79,27 @@ claude)
command=(claude --permission-mode auto) command=(claude --permission-mode auto)
[[ -n ${prompt:-} ]] && command+=(-- "$prompt") [[ -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) grok)
command=(grok --permission-mode bypassPermissions) command=(grok --permission-mode bypassPermissions)
[[ -n ${prompt:-} ]] && command+=(-- "$prompt") [[ -n ${prompt:-} ]] && command+=(-- "$prompt")
;; ;;
openclaw) kimi)
# The launcher owns onboarding and the gateway dance: OpenClaw's terminal UI # --prompt is kimi's one-shot form, like `crush run`; --auto spares its questions.
# must attach to the running gateway (the embedded `openclaw chat` refuses to command=(kimi --auto)
# start while the gateway owns the state directory). It has no permission [[ -n ${prompt:-} ]] && command+=(--prompt "$prompt")
# prompts to skip, and --message seeds the session while keeping it
# interactive.
command=(omarchy-launch-openclaw --tui)
[[ -n ${prompt:-} ]] && command+=(--message "$prompt")
;; ;;
codex) codex)
command=(codex --approve-for-me) command=(codex --approve-for-me)
[[ -n ${prompt:-} ]] && command+=(-- "$prompt") [[ -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) omp)
command=(omp --auto-approve) command=(omp --auto-approve)
[[ -n ${prompt:-} ]] && command+=(-- "$prompt") [[ -n ${prompt:-} ]] && command+=(-- "$prompt")
@@ -136,29 +121,6 @@ pi)
;; ;;
esac 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 if [[ $inline == "true" ]]; then
exec "${command[@]}" exec "${command[@]}"
else else
-223
View File
@@ -1,223 +0,0 @@
#!/bin/bash
# omarchy:summary=Sign in to a Claude, Codex, or Grok subscription
# omarchy:args=[--check] [--events] [--reauth <account>] [claude|codex|grok] [label]
# omarchy:examples=omarchy agent account add claude | omarchy agent account add Work | omarchy agent account add codex Side
set -uo pipefail
# --check prints, per provider, whether adding one now would be its first
# sign-in, an additional account, or not possible yet. --events is for the
# agents panel: progress arrives as `@@omarchy <event> <detail>` lines mixed
# into the CLI's own output (which carries codes the panel shows), nothing
# waits on a terminal, and the result also lands as a notification in case
# the panel has closed.
# --reauth <account> signs an account that's already here in again, in its
# own home, for when its sign-in has lapsed.
check=false
events=false
reauth=""
while [[ ${1:-} == --* ]]; do
case "$1" in
--check) check=true ;;
--events) events=true ;;
--reauth)
reauth="${2:-}"
shift
;;
*) break ;;
esac
shift
done
# Whether a provider's own home (~/.claude, ~/.codex, ~/.grok) is signed in.
primary_signed_in() {
omarchy-agent-account-state list "$1" | jq -e '.[0].accounts | any(.primary and .signedIn)' >/dev/null
}
if [[ $check == "true" ]]; then
for p in claude codex grok; do
if primary_signed_in "$p"; then
echo "$p additional"
else
echo "$p first"
fi
done
exit 0
fi
# The provider can be left out when it's your default agent:
# `omarchy agent account add Work`.
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
default=$(omarchy-default-agent)
[[ $default == "claude" || $default == "codex" || $default == "grok" ]] && set -- "$default" "$@"
fi
case "${1:-}" in
claude) provider="claude"; name="Claude"; site="claude.ai"; package="claude"; home_env="CLAUDE_CONFIG_DIR" ;;
codex) provider="codex"; name="Codex"; site="chatgpt.com"; package="codex"; home_env="CODEX_HOME" ;;
grok) provider="grok"; name="Grok"; site="x.ai"; package="grok"; home_env="GROK_HOME" ;;
*)
echo "Usage: omarchy-agent-account-add [--check] [--events] [--reauth <account>] [claude|codex|grok] [label]" >&2
exit 1
;;
esac
label="${2:-}"
say() {
if [[ $events == "true" ]]; then
printf '@@omarchy status %s\n' "$*"
else
echo "$*"
fi
}
finish() {
if [[ $events == "true" ]]; then
printf '@@omarchy done %s\n' "$1"
omarchy-notification-send -g 󰚩 "$1"
else
echo
echo "$1"
fi
}
fail() {
if [[ $events == "true" ]]; then
printf '@@omarchy error %s\n' "$1"
omarchy-notification-send -g 󰚩 -u critical "Couldn't add the $name account" "$1"
else
echo "$1" >&2
fi
exit 1
}
# Cancelling from the panel stops the CLI's login too, and leaves no
# half-made home behind.
home=""
browser=""
cleanup() {
[[ -n $browser ]] && rm -f "$browser"
[[ -n $home && -d $home && $home == */.pending/* ]] && rm -rf "$home"
}
trap cleanup EXIT
trap 'pkill -TERM -P $$; exit 130' TERM INT
# Bash holds a trap until a foreground command finishes, and a login waits on
# the browser for as long as it takes. Running it in the background behind a
# `wait` lets Cancel stop it at once. It keeps this script's stdin, where the
# panel sends a pasted code.
interruptible() {
"$@" <&0 &
wait $!
}
if omarchy-cmd-missing "$provider"; then
say "Installing the $name CLI…"
if [[ $events == "true" ]]; then
mise use -g "$package" >/dev/null 2>&1 || fail "Installing the $name CLI didn't finish."
else
mise use -g "$package" || exit 1
fi
fi
# Your main browser is almost certainly signed in to the account you already
# have, and a login would quietly reuse it. The CLIs open their login page
# through $BROWSER, so this points it at a private window instead.
private_browser() {
browser=$(mktemp "${XDG_RUNTIME_DIR:-/tmp}/omarchy-account-browser.XXXXXX")
printf '#!/bin/bash\nexec omarchy-launch-browser --private "$1"\n' >"$browser"
chmod +x "$browser"
}
case "$provider" in
claude) login_command=(claude auth login) ;;
codex) login_command=(codex login) ;;
grok) login_command=(grok login) ;;
esac
# The CLI's own home, whatever this shell has pointed it at.
login() {
interruptible env -u CLAUDE_CONFIG_DIR -u CODEX_HOME -u GROK_HOME "OMARCHY_AGENT_${provider^^}_HOME=" "${login_command[@]}"
}
# Signing an account that's already here in again, where it lives: the
# primary home through the normal browser, an added one through a private
# window. Its home is never recreated or removed.
if [[ -n $reauth ]]; then
if [[ $reauth == ":primary" ]]; then
account_home="$HOME/.$provider"
else
account_home=$(omarchy-agent-account-state homes "$provider" | awk -F'\t' -v id="$reauth" '$1 == id { print $2 }')
fi
[[ -n $account_home ]] || fail "No $name account named $reauth."
if [[ $account_home == "$HOME/.$provider" ]]; then
say "Sign in to $name again in the browser window that opens."
login || fail "The $name sign-in didn't finish."
else
say "Sign in again as this account in the private window that opens."
private_browser
interruptible env BROWSER="$browser" "$home_env=$account_home" "${login_command[@]}" || fail "The $name sign-in didn't finish."
fi
omarchy-agent-usage-update --force "$provider" >/dev/null 2>&1 &
finish "Signed in to $name again."
exit 0
fi
# The first subscription signs in where the CLI always looks, through your
# normal browser: nothing is signed in there yet that it could be mistaken for.
if ! primary_signed_in "$provider"; then
say "Sign in to $name in the browser window that opens."
if login && primary_signed_in "$provider"; then
# The first agent signed in on a machine with none chosen becomes the
# default, so `omarchy agent` and the panel's starter prompts have one to
# run. A default someone already picked is never touched.
if [[ -z $(omarchy-default-agent) ]]; then
mkdir -p "$HOME/.config/omarchy/defaults"
printf '%s\n' "$provider" >"$HOME/.config/omarchy/defaults/agent"
fi
omarchy-agent-usage-update --force "$provider" >/dev/null 2>&1 &
finish "Signed in to $name."
else
fail "The $name sign-in didn't finish."
fi
else
if [[ -z $label && $events == "false" && -t 0 ]]; then
label=$(gum input --placeholder "Work" --prompt "Name this $name account: ") || exit 130
fi
if [[ $events == "true" ]]; then
say "Sign in as the account you're adding in the private window that opens."
else
cat <<EOF
Sign in as the account you're adding in the private window that opens.
If a private window was already open, close it first, or $site may reuse whoever it has signed in.
Don't log out of the $name CLI to do this: logging out revokes the saved sign-in.
EOF
fi
home=$(omarchy-agent-account-state begin "$provider") || fail "Couldn't set up a home for the new account."
private_browser
interruptible env BROWSER="$browser" "$home_env=$home" "${login_command[@]}"
if account=$(omarchy-agent-account-state register "$provider" "$label" "$home" 2>&1); then
home=""
added=$(jq -r '.label + (if .email != "" then " (" + .email + ")" else "" end)' <<<"$account")
omarchy-agent-usage-update --force "$provider" >/dev/null 2>&1 &
if [[ $events == "true" ]]; then
finish "Added $added."
else
finish "Added $added."
echo "Switch to it with: omarchy agent account use $provider $(jq -r .id <<<"$account")"
fi
else
home=""
fail "$account"
fi
fi
-43
View File
@@ -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" "$@"
-25
View File
@@ -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
-58
View File
@@ -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
-30
View File
@@ -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'
-30
View File
@@ -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 &
-24
View File
@@ -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 &
-787
View File
@@ -1,787 +0,0 @@
#!/usr/bin/python3
# omarchy:summary=Read and change the registry of Claude, Codex, and Grok subscription accounts
# omarchy:args=<registry|home|homes|list|refresh|begin|register|use|rename|remove|mode|link|autoswitch> [provider] [args...]
# omarchy:hidden=true
"""The account registry behind `omarchy agent account`.
An account is a Claude Code, Codex, or Grok login Omarchy keeps a home for.
The login you already have stays where the CLI put it (~/.claude, ~/.codex,
~/.grok) and
is the primary account; added accounts get a home of their own under
~/.local/state/omarchy/agents/accounts/<provider>/<id>/ that holds only that
account's credentials and links everything else back to the primary home, so
conversation history, settings and skills stay one set across accounts.
Each provider's registry is one JSON file beside those homes. It records which
account is active — the one new sessions start as — how switching happens, and
display-safe identity. Tokens never enter it and never leave this process.
"""
from __future__ import annotations
import base64
import datetime as dt
import fcntl
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any
PROVIDERS = {
"claude": {
"name": "Claude",
"home_env": "CLAUDE_CONFIG_DIR",
# Everything a session reads or writes that isn't tied to the login. A
# missing directory is created in the primary home first; a missing file
# is linked anyway, so whichever account writes it first writes it there.
"shared_dirs": ["projects", "skills", "agents", "commands", "plugins", "hooks", "themes", "output-styles", "file-history", "todos", "plans"],
"shared_files": ["settings.json", "CLAUDE.md", "history.jsonl", "keybindings.json"],
# Keys copied from the primary's ~/.claude.json into a new account's, so a
# second login keeps MCP servers, folder trust and onboarding choices.
"carried_keys": ["mcpServers", "projects", "theme", "hasCompletedOnboarding", "lastOnboardingVersion", "editorMode"],
},
"codex": {
"name": "Codex",
"home_env": "CODEX_HOME",
"shared_dirs": ["sessions", "archived_sessions", "prompts", "skills", "rules", "plugins"],
"shared_files": ["config.toml", "AGENTS.md", "history.jsonl", "hooks.json"],
"carried_keys": [],
},
"grok": {
"name": "Grok",
"home_env": "GROK_HOME",
"shared_dirs": ["sessions", "skills", "agents", "plugins", "installed-plugins", "marketplace-cache", "hooks", "memory", "memory-v2", "rules", "commands", "workflows", "personas", "worktrees"],
"shared_files": ["config.toml", "settings.json", "lsp.json", "pager.toml", "sandbox.toml", "trusted_folders.toml", "statusline.sh", "worktrees.db"],
"carried_keys": [],
},
}
DEFAULT_THRESHOLD = 95
# Ids an account can't take because something else already answers to them:
# `use next` cycles, a provider name is read as the provider when it comes
# first, and the menu's Add Account row is `.add`.
RESERVED_IDS = {"next", "add", "claude", "codex", "grok", "primary"}
SWITCH_MODES = ("manual", "auto")
class AccountError(Exception):
pass
# ------------------------------------------------------------------- paths
def state_root() -> Path:
return Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state") / "omarchy" / "agents"
def accounts_root() -> Path:
return state_root() / "accounts"
def registry_path(provider: str) -> Path:
return accounts_root() / f"{provider}.json"
def usage_record_path(provider: str) -> Path:
return state_root() / "usage" / f"{provider}.json"
# The primary home is where the CLI keeps a login when nothing tells it
# otherwise. It is deliberately not read from CLAUDE_CONFIG_DIR / CODEX_HOME /
# GROK_HOME:
# those are how an added account is selected, so honoring them here would make
# whichever account launched this command look like the primary.
def primary_home(provider: str) -> Path:
return Path.home() / f".{provider}"
def account_home(provider: str, account: dict[str, Any]) -> Path:
home = str(account.get("home") or "")
return Path(home) if home else primary_home(provider)
# Claude keeps its account file beside the config dir for the default home
# (~/.claude.json) and inside it for any other.
def claude_account_file(home: Path) -> Path:
if home == primary_home("claude"):
return Path.home() / ".claude.json"
return home / ".claude.json"
# --------------------------------------------------------------- identity
def read_json(path: Path) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
def plan_label(tier: str, subscription: str) -> str:
match = re.search(r"max_(\d+x)", tier or "", re.IGNORECASE)
if match:
return "Max " + match.group(1)
if subscription:
return subscription[0].upper() + subscription[1:]
return ""
def jwt_claims(token: str) -> dict[str, Any]:
try:
payload = token.split(".")[1]
payload += "=" * (-len(payload) % 4)
claims = json.loads(base64.urlsafe_b64decode(payload.encode("ascii")))
return claims if isinstance(claims, dict) else {}
except Exception:
return {}
# Who a home is signed in as, from files the CLI wrote at login. Only
# display-safe fields come out; an empty accountId means nobody is signed in.
def identity(provider: str, home: Path) -> dict[str, str]:
if provider == "claude":
account = (read_json(claude_account_file(home)) or {}).get("oauthAccount") or {}
login = (read_json(home / ".credentials.json") or {}).get("claudeAiOauth") or {}
return {
"accountId": str(account.get("accountUuid") or ""),
"email": str(account.get("emailAddress") or ""),
"org": str(account.get("organizationName") or ""),
"plan": plan_label(str(login.get("rateLimitTier") or ""), str(login.get("subscriptionType") or "")),
}
if provider == "grok":
return grok_identity(home)
auth = read_json(home / "auth.json") or {}
tokens = auth.get("tokens") or {}
claims = jwt_claims(str(tokens.get("id_token") or ""))
openai = claims.get("https://api.openai.com/auth") or {}
plan = str(openai.get("chatgpt_plan_type") or "")
return {
"accountId": str(tokens.get("account_id") or openai.get("chatgpt_account_id") or ""),
"email": str(claims.get("email") or ""),
"org": "",
"plan": plan[0].upper() + plan[1:] if plan else "",
}
# Grok keeps one login per issuer in auth.json, and the plan only in the
# settings it caches from xAI, as a JSON string inside that file.
def grok_identity(home: Path) -> dict[str, str]:
auth = read_json(home / "auth.json") or {}
login = next((v for v in auth.values() if isinstance(v, dict)), {}) if isinstance(auth, dict) else {}
try:
settings = json.loads((read_json(home / "settings_cache.json") or {}).get("payload") or "{}").get("settings") or {}
except Exception:
settings = {}
return {
"accountId": str(login.get("user_id") or ""),
"email": str(login.get("email") or ""),
"org": "",
"plan": str(settings.get("subscription_tier_display") or ""),
}
# ---------------------------------------------------------------- registry
def slug(label: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", label.lower()).strip("-")
def empty_registry(provider: str) -> dict[str, Any]:
return {"active": "main", "switch": "manual", "threshold": DEFAULT_THRESHOLD, "alert": "", "accounts": []}
def primary_entry(provider: str) -> dict[str, Any]:
entry = {"id": "main", "label": "Main", "home": "", "primary": True}
entry.update(identity(provider, primary_home(provider)))
return entry
def normalized(provider: str, data: Any) -> dict[str, Any]:
registry = empty_registry(provider)
if isinstance(data, dict):
registry.update({key: data[key] for key in registry if key in data})
accounts = [a for a in registry["accounts"] if isinstance(a, dict) and a.get("id")]
if not any(a.get("primary") for a in accounts):
accounts.insert(0, primary_entry(provider))
registry["accounts"] = accounts
if registry["switch"] not in SWITCH_MODES:
registry["switch"] = "manual"
try:
registry["threshold"] = max(50, min(100, int(registry["threshold"])))
except Exception:
registry["threshold"] = DEFAULT_THRESHOLD
if not find(registry, registry["active"]):
registry["active"] = accounts[0]["id"]
return registry
def load(provider: str) -> dict[str, Any]:
return normalized(provider, read_json(registry_path(provider)))
def save(provider: str, registry: dict[str, Any]) -> None:
path = registry_path(provider)
path.parent.mkdir(parents=True, exist_ok=True)
os.chmod(path.parent, 0o700)
fd, tmp = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent))
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(registry, handle, indent=2)
handle.write("\n")
os.chmod(tmp, 0o600)
os.replace(tmp, path)
except Exception:
Path(tmp).unlink(missing_ok=True)
raise
# Every change is read-modify-write under one lock per provider, so the panel,
# the autoswitch run after each usage update, and a command typed at a prompt
# can't interleave and drop each other's edits.
class Locked:
def __init__(self, provider: str):
self.provider = provider
def __enter__(self) -> dict[str, Any]:
accounts_root().mkdir(parents=True, exist_ok=True)
self.handle = open(accounts_root() / f".{self.provider}.lock", "w")
fcntl.flock(self.handle, fcntl.LOCK_EX)
self.registry = load(self.provider)
return self.registry
def __exit__(self, kind, value, traceback) -> None:
try:
if kind is None:
save(self.provider, self.registry)
finally:
fcntl.flock(self.handle, fcntl.LOCK_UN)
self.handle.close()
def find(registry: dict[str, Any], account_id: str) -> dict[str, Any] | None:
for account in registry["accounts"]:
if account.get("id") == account_id:
return account
# `primary` names the login that was there first, whatever it's called now.
if account_id == "primary":
for account in registry["accounts"]:
if account.get("primary"):
return account
return None
def active(registry: dict[str, Any]) -> dict[str, Any]:
return find(registry, registry["active"]) or registry["accounts"][0]
# ------------------------------------------------------------------- homes
# Lay the links that make an added account share everything but its login
# with the primary home. Safe to repeat: an existing link or real file is left
# as it is, so nothing an account wrote for itself is ever replaced.
def link_shared(provider: str, home: Path) -> None:
spec = PROVIDERS[provider]
primary = primary_home(provider)
home.mkdir(parents=True, exist_ok=True)
os.chmod(home, 0o700)
for name in spec["shared_dirs"]:
(primary / name).mkdir(parents=True, exist_ok=True)
for name in spec["shared_dirs"] + spec["shared_files"]:
target = home / name
if target.exists() or target.is_symlink():
continue
target.symlink_to(primary / name)
def carry_settings(provider: str, home: Path) -> None:
keys = PROVIDERS[provider]["carried_keys"]
if not keys:
return
source = read_json(claude_account_file(primary_home(provider))) or {}
path = claude_account_file(home)
data = read_json(path) or {}
for key in keys:
if key in source and key not in data:
data[key] = source[key]
fd, tmp = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent))
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(data, handle, indent=2)
os.chmod(tmp, 0o600)
os.replace(tmp, path)
# A login in progress gets a scratch home beside the real ones, so an
# abandoned or duplicate login never shows up as an account.
def begin(provider: str) -> Path:
pending = accounts_root() / provider / ".pending"
pending.mkdir(parents=True, exist_ok=True)
os.chmod(pending.parent, 0o700)
home = Path(tempfile.mkdtemp(prefix="login-", dir=str(pending)))
link_shared(provider, home)
return home
def register(provider: str, label: str, pending: Path) -> dict[str, Any]:
pending = pending.resolve()
if pending.parent != (accounts_root() / provider / ".pending").resolve():
raise AccountError(f"{pending} is not a pending {PROVIDERS[provider]['name']} login")
found = identity(provider, pending)
if not found["accountId"]:
shutil.rmtree(pending, ignore_errors=True)
raise AccountError("The login didn't finish, so no account was added.")
with Locked(provider) as registry:
# Each home is asked who it is now, not trusted from when it was added: a
# `claude auth login` run by hand in ~/.claude changes the primary.
for account in registry["accounts"]:
account.update(identity(provider, account_home(provider, account)))
for account in registry["accounts"]:
if account.get("accountId") == found["accountId"]:
shutil.rmtree(pending, ignore_errors=True)
raise AccountError(
f"That's {account['label']} ({found['email'] or 'already added'}). "
"Try again, and sign in as the other account in the private window."
)
label = label.strip() or found["email"].split("@")[0] or "Account"
account_id = unique_id(provider, registry, label)
# Everything that can fail happens while the login is still pending, where
# the add command cleans it up, or is undone: a home that isn't in the
# registry would keep a sign-in nothing can manage.
carry_settings(provider, pending)
home = accounts_root() / provider / account_id
pending.rename(home)
entry = {"id": account_id, "label": label, "home": str(home), "primary": False}
entry.update(found)
registry["accounts"].append(entry)
try:
save(provider, registry)
except Exception:
registry["accounts"].pop()
home.rename(pending)
raise
return entry
# The id an account answers to, from its label. It can't clash with another
# account, a routing keyword, or a home left under that name by an account
# that has since been renamed.
def unique_id(provider: str, registry: dict[str, Any], label: str, keep: dict[str, Any] | None = None) -> str:
base = slug(label) or "account"
account_id, n = base, 2
while True:
taken = find(registry, account_id)
if account_id not in RESERVED_IDS and (taken is None or taken is keep):
if keep is not None or not (accounts_root() / provider / account_id).exists():
return account_id
account_id, n = f"{base}-{n}", n + 1
# A new label, and the id that follows from it. The home stays where it is:
# sessions already running in it hold that path.
def rename(provider: str, target: str, label: str) -> dict[str, Any]:
label = label.strip()
if not label:
raise AccountError("Give the account a name")
with Locked(provider) as registry:
account = find(registry, target)
if not account:
raise AccountError(f"No {PROVIDERS[provider]['name']} account named {target}")
new_id = unique_id(provider, registry, label, keep=account)
if not account.get("primary") and not account.get("home"):
account["home"] = str(account_home(provider, account))
if registry["active"] == account["id"]:
registry["active"] = new_id
registry["alert"] = ""
account["id"] = new_id
account["label"] = label
return dict(account)
# Save who each home is signed in as now. A login run in a home, through the
# panel or the CLI, changes it, and the usage records take their names and
# emails from here.
def refresh(provider: str) -> None:
with Locked(provider) as registry:
for account in registry["accounts"]:
account.update(identity(provider, account_home(provider, account)))
def use(provider: str, target: str) -> tuple[dict[str, Any], dict[str, Any]]:
with Locked(provider) as registry:
previous = active(registry)
if target == "next":
ids = [a["id"] for a in registry["accounts"]]
target = ids[(ids.index(previous["id"]) + 1) % len(ids)]
account = find(registry, target)
if not account:
raise AccountError(f"No {PROVIDERS[provider]['name']} account named {target}")
registry["active"] = account["id"]
registry["alert"] = ""
if not account.get("primary"):
link_shared(provider, account_home(provider, account))
return previous, account
# Whether a running session was started in this home. Removing it would pull
# the login out from under that session, which must never be touched.
def home_in_use(provider: str, home: Path) -> bool:
wanted = f"{PROVIDERS[provider]['home_env']}=".encode()
resolved = home.resolve()
for environ in Path("/proc").glob("[0-9]*/environ"):
try:
entries = environ.read_bytes().split(b"\0")
except OSError:
continue
for entry in entries:
if entry.startswith(wanted) and Path(entry[len(wanted):].decode(errors="replace")).resolve() == resolved:
return True
return False
def remove(provider: str, target: str) -> dict[str, Any]:
with Locked(provider) as registry:
account = find(registry, target)
if not account:
raise AccountError(f"No {PROVIDERS[provider]['name']} account named {target}")
if account.get("primary"):
raise AccountError(f"{account['label']} is the login in {primary_home(provider)}; it can't be removed here.")
home = account_home(provider, account)
if home_in_use(provider, home):
raise AccountError(f"A running {PROVIDERS[provider]['name']} session uses {account['label']}. Quit it first, then remove the account.")
# Only the account's own directory goes. Its links point into the primary
# home, and rmtree removes a link without following it.
if home.resolve().parent == (accounts_root() / provider).resolve():
shutil.rmtree(home, ignore_errors=True)
registry["accounts"] = [a for a in registry["accounts"] if a["id"] != account["id"]]
if registry["active"] == account["id"]:
registry["active"] = registry["accounts"][0]["id"]
return account
def set_mode(provider: str, mode: str, threshold: str | None) -> dict[str, Any]:
if mode not in SWITCH_MODES:
raise AccountError("Switch mode is manual or auto")
with Locked(provider) as registry:
registry["switch"] = mode
if threshold is not None:
try:
registry["threshold"] = max(50, min(100, int(threshold)))
except ValueError:
raise AccountError("The threshold is a percentage between 50 and 100")
registry["alert"] = ""
return dict(registry)
# ------------------------------------------------------------- autoswitch
def window_open(limit: dict[str, Any], now: dt.datetime) -> bool:
raw = str(limit.get("resetsAt") or "")
if not raw:
return True
try:
resets_at = dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
except Exception:
return True
if resets_at.tzinfo is None:
resets_at = resets_at.replace(tzinfo=dt.timezone.utc)
return resets_at > now
# How spent an account is: its fullest window that hasn't reset yet, in
# percent. A window whose reset has passed counts as empty even when the
# numbers are stale, since that's exactly what a reset means. None when
# nothing is known at all.
def peak(limits: Any, now: dt.datetime) -> float | None:
if not isinstance(limits, list):
return None
known = [l for l in limits if isinstance(l, dict) and isinstance(l.get("percent"), (int, float))]
if not known:
return None
open_windows = [float(l["percent"]) * 100 for l in known if window_open(l, now)]
return max(open_windows) if open_windows else 0.0
def next_reset(limits: Any, now: dt.datetime) -> dt.datetime | None:
times = []
for limit in limits if isinstance(limits, list) else []:
try:
when = dt.datetime.fromisoformat(str(limit.get("resetsAt") or "").replace("Z", "+00:00"))
except Exception:
continue
if when.tzinfo is None:
when = when.replace(tzinfo=dt.timezone.utc)
if when > now:
times.append(when)
return min(times) if times else None
# When an account over the threshold drops back under it: once every window
# holding it there has reset, which is the latest of those resets. A 10%
# session resetting in an hour doesn't help an account whose weekly window is
# full for six more days. None when a blocking window never says.
def available_at(limits: Any, threshold: float, now: dt.datetime) -> dt.datetime | None:
latest = None
for limit in limits if isinstance(limits, list) else []:
if not isinstance(limit, dict) or not isinstance(limit.get("percent"), (int, float)):
continue
if float(limit["percent"]) * 100 < threshold or not window_open(limit, now):
continue
try:
when = dt.datetime.fromisoformat(str(limit.get("resetsAt") or "").replace("Z", "+00:00"))
except Exception:
return None
if when.tzinfo is None:
when = when.replace(tzinfo=dt.timezone.utc)
latest = when if latest is None or when > latest else latest
return latest
def duration(delta: dt.timedelta) -> str:
minutes = max(1, int(delta.total_seconds() // 60))
hours, minutes = divmod(minutes, 60)
days, hours = divmod(hours, 24)
if days:
return f"{days}d {hours}h"
if hours:
return f"{hours}h {minutes}m"
return f"{minutes}m"
# The whole switching policy, with no I/O: given the registry and the per
# account limits from the usage record, what should happen. Returns None when
# nothing should, or {"action": "switch"|"notify"|"exhausted", ...}.
#
# Only the active account crossing the threshold starts anything, so auto
# mode never flaps back to an account that just reset. `alert` remembers the
# last thing said, so a shell restart or the next refresh doesn't repeat it.
#
# Numbers kept from an earlier check may be out of date, but a parked account's
# sign-in lapses within hours and can't be checked again until a session runs
# in it. So a stale account is still a candidate: among accounts with room, one
# checked just now wins, but room comes first. An account within 15 points of
# the threshold (where the panel starts checking more often) is near its limit
# however fresh its numbers are.
def decide(registry: dict[str, Any], usage: dict[str, Any], now: dt.datetime, stale: frozenset[str] = frozenset()) -> dict[str, Any] | None:
threshold = float(registry["threshold"])
current = active(registry)
current_peak = peak(usage.get(current["id"]), now)
if current_peak is None or current_peak < threshold:
return {"action": "clear"} if registry.get("alert") else None
candidates = []
for account in registry["accounts"]:
if account["id"] == current["id"]:
continue
level = peak(usage.get(account["id"]), now)
if level is None or level >= threshold:
continue
reset = next_reset(usage.get(account["id"]), now) or dt.datetime.max.replace(tzinfo=dt.timezone.utc)
candidates.append((level >= threshold - 15, account["id"] in stale, level, reset, account))
candidates.sort(key=lambda c: c[:4])
if candidates:
_, _, level, _, target = candidates[0]
action = "switch" if registry["switch"] == "auto" else "notify"
alert = f"{action}:{current['id']}:{target['id']}"
if registry.get("alert") == alert:
return None
return {"action": action, "alert": alert, "from": current, "to": target, "fromPeak": current_peak, "toPeak": level}
alert = f"exhausted:{current['id']}"
if registry.get("alert") == alert or len(registry["accounts"]) < 2:
return None
# Only when every account is known to be over. One that's signed out or
# couldn't be checked might have room, so there's nothing honest to say.
if any(peak(usage.get(a["id"]), now) is None for a in registry["accounts"]):
return None
soonest = None
for account in registry["accounts"]:
reset = available_at(usage.get(account["id"]), threshold, now)
if reset and (soonest is None or reset < soonest[0]):
soonest = (reset, account)
return {"action": "exhausted", "alert": alert, "from": current, "fromPeak": current_peak, "soonest": soonest}
# Limits per account id. An account nobody is signed in to is left out, so it
# is never a candidate: its cached headroom is no use to a session that can't
# start. One whose token merely expired stays in, since a session refreshes it.
def usage_by_account(provider: str) -> tuple[dict[str, Any], frozenset[str]]:
record = read_json(usage_record_path(provider)) or {}
accounts = [
a for a in record.get("accounts") or []
if isinstance(a, dict) and a.get("id") and a.get("usageStatusText") != "Waiting for auth"
]
return (
{str(a["id"]): a.get("limits") for a in accounts},
frozenset(str(a["id"]) for a in accounts if a.get("stale") is True),
)
def notify(title: str, body: str, action: list[str] | None = None) -> None:
command = ["omarchy-notification-send", "--app-name", "Omarchy Agents", "-g", "󰚩", title, body]
if action:
command += ["--exec"] + action
subprocess.run(command, check=False)
# Returns True when the active account changed.
def autoswitch(provider: str, now: dt.datetime | None = None) -> bool:
now = now or dt.datetime.now(dt.timezone.utc)
name = PROVIDERS[provider]["name"]
with Locked(provider) as registry:
if len(registry["accounts"]) < 2:
return False
usage, stale = usage_by_account(provider)
decision = decide(registry, usage, now, stale)
if decision is None:
return False
if decision["action"] == "clear":
registry["alert"] = ""
return False
registry["alert"] = decision["alert"]
source = decision["from"]
threshold = registry["threshold"]
if decision["action"] == "exhausted":
soonest = decision["soonest"]
when = f" {soonest[1]['label']} resets in {duration(soonest[0] - now)}." if soonest else ""
notify(f"All {name} accounts are over {threshold}%", f"Staying on {source['label']}.{when}")
return False
target = decision["to"]
headline = f"{source['label']} is at {round(decision['fromPeak'])}% of a {name} limit"
if decision["action"] == "switch":
registry["active"] = target["id"]
if not target.get("primary"):
link_shared(provider, account_home(provider, target))
notify(headline, f"New {name} sessions now use {target['label']} ({round(decision['toPeak'])}%). Running sessions stay on {source['label']}.")
return True
notify(
headline,
f"{target['label']} is at {round(decision['toPeak'])}%. Click to switch new sessions to it.",
["omarchy-agent-account-use", provider, target["id"]],
)
return False
# --------------------------------------------------------------------- CLI
def provider_arg(args: list[str]) -> str:
if not args or args[0] not in PROVIDERS:
raise AccountError("Name a provider: claude, codex, or grok")
return args[0]
def summary(provider: str, registry: dict[str, Any]) -> dict[str, Any]:
# Identity is re-read from each home rather than trusted from the registry:
# a `claude auth login` run by hand in a home changes who it is.
for account in registry["accounts"]:
account.update(identity(provider, account_home(provider, account)))
return {
"provider": provider,
"name": PROVIDERS[provider]["name"],
"active": registry["active"],
"switch": registry["switch"],
"threshold": registry["threshold"],
"accounts": [
{key: account.get(key, "") for key in ("id", "label", "email", "org", "plan", "primary")}
| {
"home": str(account_home(provider, account)),
"active": account["id"] == registry["active"],
"signedIn": bool(account.get("accountId")),
}
for account in registry["accounts"]
],
}
def main(argv: list[str]) -> int:
if not argv:
print(__doc__.strip().splitlines()[0], file=sys.stderr)
return 1
command, args = argv[0], argv[1:]
try:
if command == "registry":
provider = provider_arg(args)
print(json.dumps(summary(provider, load(provider))))
elif command == "list":
providers = [provider_arg(args)] if args else list(PROVIDERS)
print(json.dumps([summary(p, load(p)) for p in providers]))
elif command == "home":
# Empty for the primary, so launching it needs no environment at all.
provider = provider_arg(args)
registry = read_json(registry_path(provider))
if registry:
account = active(normalized(provider, registry))
if not account.get("primary"):
print(account_home(provider, account))
elif command == "homes":
provider = provider_arg(args)
registry = load(provider)
for account in registry["accounts"]:
print("\t".join([account["id"], str(account_home(provider, account)), "1" if account["id"] == registry["active"] else "0"]))
elif command == "refresh":
for provider in ([provider_arg(args)] if args else list(PROVIDERS)):
if registry_path(provider).exists():
refresh(provider)
elif command == "begin":
print(begin(provider_arg(args)))
elif command == "register":
provider = provider_arg(args)
if len(args) < 3:
raise AccountError("register <provider> <label> <pending-home>")
print(json.dumps(register(provider, args[1], Path(args[2]))))
elif command == "use":
provider = provider_arg(args)
previous, account = use(provider, args[1] if len(args) > 1 else "next")
print(json.dumps({"previous": previous, "active": account}))
elif command == "remove":
provider = provider_arg(args)
if len(args) < 2:
raise AccountError("remove <provider> <account>")
print(json.dumps(remove(provider, args[1])))
elif command == "rename":
provider = provider_arg(args)
if len(args) < 3:
raise AccountError("rename <provider> <account> <label>")
print(json.dumps(rename(provider, args[1], " ".join(args[2:]))))
elif command == "mode":
provider = provider_arg(args)
if len(args) < 2:
raise AccountError("mode <provider> <manual|auto> [threshold]")
set_mode(provider, args[1], args[2] if len(args) > 2 else None)
elif command == "link":
provider = provider_arg(args)
link_shared(provider, Path(args[1]))
elif command == "autoswitch":
# Prints each provider whose active account changed, so the caller
# knows which usage records to collect again.
for provider in ([provider_arg(args)] if args else list(PROVIDERS)):
if registry_path(provider).exists() and autoswitch(provider):
print(provider)
else:
raise AccountError(f"Unknown command: {command}")
except AccountError as error:
print(error, file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
-39
View File
@@ -1,39 +0,0 @@
#!/bin/bash
# omarchy:summary=Choose which Claude, Codex, or Grok account new sessions use
# omarchy:args=[claude|codex|grok] [account|next]
# omarchy:examples=omarchy agent account use work | omarchy agent account use main | omarchy agent account use codex next
set -uo pipefail
# The provider can be left out when it's your default agent:
# `omarchy agent account use work`.
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
default=$(omarchy-default-agent)
[[ $default == "claude" || $default == "codex" || $default == "grok" ]] && set -- "$default" "$@"
fi
case "${1:-}" in
claude) name="Claude" ;;
codex) name="Codex" ;;
grok) name="Grok" ;;
*)
echo "Usage: omarchy-agent-account-use [claude|codex|grok] [account|next]" >&2
exit 1
;;
esac
result=$(omarchy-agent-account-state use "$1" "${2:-next}") || exit 1
previous=$(jq -r .previous.label <<<"$result")
active=$(jq -r '.active.label + (if (.active.plan // "") != "" then " (" + .active.plan + ")" else "" end)' <<<"$result")
if [[ $(jq -r .previous.id <<<"$result") == "$(jq -r .active.id <<<"$result")" ]]; then
echo "New $name sessions already use $active."
else
message="Running sessions stay on $previous."
echo "New $name sessions now use $active. $message"
omarchy-notification-send -g 󰚩 "New $name sessions now use $active" "$message"
# The usage record's own limits describe the active account.
omarchy-agent-usage-update --limits-only "$1" >/dev/null 2>&1 &
fi
+106 -487
View File
@@ -35,10 +35,6 @@ AGENT_NAME = "Claude Code"
AUTH_HELP = "Run `claude auth login` to restore authoritative usage." AUTH_HELP = "Run `claude auth login` to restore authoritative usage."
USAGE_ENDPOINT = "https://api.anthropic.com/api/oauth/usage" USAGE_ENDPOINT = "https://api.anthropic.com/api/oauth/usage"
PROBE_MIN_INTERVAL_SECONDS = 15 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: def config_dir() -> Path:
@@ -122,209 +118,12 @@ def empty_bucket() -> dict[str, int]:
# ---------------------------------------------------------------- local scan # ---------------------------------------------------------------- local scan
# Transcripts are append-only, and most of the corpus never changes between def scan_projects(projects_path: Path) -> dict[str, Any]:
# 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]:
today = local_date_string() today = local_date_string()
recent_dates = recent_date_strings() recent_dates = recent_date_strings()
recent = {day: {"date": day, "messageCount": 0} for day in recent_dates} recent = {day: {"date": day, "messageCount": 0} for day in recent_dates}
# One API message can show up in several transcripts (a resumed session seen: set[str] = set()
# 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]] = {}
sessions: set[str] = set() sessions: set[str] = set()
active_days: set[str] = set() active_days: set[str] = set()
today_sessions: set[str] = set() today_sessions: set[str] = set()
@@ -334,38 +133,68 @@ def summarize_records(indexed: dict[str, Any], order: list[str]) -> dict[str, An
today_prompt_count = 0 today_prompt_count = 0
today_token_total = 0 today_token_total = 0
for key in order: files = projects_path.rglob("*.jsonl") if projects_path.is_dir() else []
for record in indexed[key].get("records") or []: for path in files:
held = seen.get(record[0]) try:
if held is not None and record[4] < held[4]: with path.open("r", encoding="utf-8", errors="replace") as handle:
continue for line_number, line in enumerate(handle, 1):
seen[record[0]] = record # 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(): try:
if day is None: entry = json.loads(line)
day = today except Exception:
total = input_tokens + output_tokens + cache_read + cache_write continue
sessions.add(session_key) message = entry.get("message") if isinstance(entry.get("message"), dict) else {}
active_days.add(day) if entry.get("type") != "assistant" and message.get("role") != "assistant":
prompts += 1 continue
bucket = usage_by_model.setdefault(model, empty_bucket()) usage = message.get("usage") or entry.get("usage")
bucket["inputTokens"] += input_tokens if not isinstance(usage, dict):
bucket["outputTokens"] += output_tokens continue
bucket["cacheReadInputTokens"] += cache_read
bucket["cacheCreationInputTokens"] += cache_write
if day in recent: message_id = message.get("id") or entry.get("messageId") or ""
# recentDays.messageCount is actually a token total, despite the unique_key = str(message_id) if message_id else f"{path}:{entry.get('uuid') or entry.get('requestId') or line_number}"
# legacy name shared with synced snapshots. if unique_key in seen:
recent[day]["messageCount"] += total continue
seen.add(unique_key)
if day == today: input_tokens = usage_token(usage, "input_tokens", "inputTokens")
today_prompt_count += 1 output_tokens = usage_token(usage, "output_tokens", "outputTokens")
today_sessions.add(session_key) cache_read = usage_token(usage, "cache_read_input_tokens", "cacheReadInputTokens")
today_token_total += total cache_write = usage_token(usage, "cache_creation_input_tokens", "cacheCreationInputTokens")
today_tokens[model] = today_tokens.get(model, 0) + total 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 { return {
"todayPrompts": today_prompt_count, "todayPrompts": today_prompt_count,
@@ -384,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] digest = hashlib.sha1(str(projects_path).encode("utf-8")).hexdigest()[:16]
root = cache_root() root = cache_root()
return root / f"claude-scan-{digest}.json", root / f"claude-scan-{digest}.lock", root / f"claude-index-{digest}.json" return root / f"claude-scan-{digest}.json", root / f"claude-scan-{digest}.lock"
def read_json(path: Path) -> dict[str, Any] | None:
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
def read_fresh_json(path: Path, max_age_seconds: float) -> dict[str, Any] | None: def read_fresh_json(path: Path, max_age_seconds: float) -> dict[str, Any] | None:
if max_age_seconds <= 0 or not path.exists(): if max_age_seconds <= 0 or not path.exists():
return None return None
try: try:
# A negative age means the mtime is in the future: the clock moved if time.time() - path.stat().st_mtime <= max_age_seconds:
# backwards since the write, so the cache's freshness cannot be trusted. return json.loads(path.read_text(encoding="utf-8"))
age = time.time() - path.stat().st_mtime
if 0 <= age <= max_age_seconds:
return read_json(path)
except Exception: except Exception:
return None return None
return None return None
@@ -429,8 +248,8 @@ def write_json(path: Path, payload: dict[str, Any]) -> None:
raise raise
def cached_scan(projects_path: Path, max_age_seconds: float, use_index: bool = True) -> dict[str, Any]: def cached_scan(projects_path: Path, max_age_seconds: float) -> dict[str, Any]:
cache_file, lock_file, index_file = scan_cache_paths(projects_path) cache_file, lock_file = scan_cache_paths(projects_path)
cached = read_fresh_json(cache_file, max_age_seconds) cached = read_fresh_json(cache_file, max_age_seconds)
if cached is not None: if cached is not None:
@@ -441,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) cached = read_fresh_json(cache_file, max_age_seconds)
if cached is not None: if cached is not None:
return cached return cached
if not use_index: summary = scan_projects(projects_path)
index_file.unlink(missing_ok=True)
summary = scan_projects(projects_path, index_file)
write_json(cache_file, summary) write_json(cache_file, summary)
return summary return summary
@@ -520,27 +337,11 @@ def today_prompts_from_history(claude_dir: Path) -> tuple[int, int]:
# provider, model, and token usage on every assistant message. # 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: 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" cache_file = cache_root() / "claude-pi-sessions.json"
cached = read_fresh_json(cache_file, max_age_seconds) cached = read_fresh_json(cache_file, max_age_seconds)
if cached is not None: if cached is not None:
@@ -672,36 +473,16 @@ def scan_opencode_usage(max_age_seconds: float) -> dict[str, Any] | None:
return None return None
try: try:
conn.execute("PRAGMA query_only = ON") conn.execute("PRAGMA query_only = ON")
tables = {name for (name,) in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")} for session_id, raw in conn.execute("SELECT session_id, data FROM message"):
# 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:
# One malformed row must not abort the scan, so every shape assumption # One malformed row must not abort the scan, so every shape assumption
# lives inside the try. # lives inside the try.
try: try:
entry = json.loads(raw) 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 # Exact match: opencode provider ids are free-form, and a custom
# "anthropic-proxy" gateway is not this subscription. # "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 continue
tokens = entry.get("tokens") or {} tokens = entry.get("tokens") or {}
cache = tokens.get("cache") or {} cache = tokens.get("cache") or {}
@@ -716,10 +497,10 @@ def scan_opencode_usage(max_age_seconds: float) -> dict[str, Any] | None:
created = number((entry.get("time") or {}).get("created")) created = number((entry.get("time") or {}).get("created"))
day = dt.datetime.fromtimestamp(created / 1000).strftime("%Y-%m-%d") if created > 0 else today 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: except Exception:
continue continue
session_key = session_prefix + str(session_id) session_key = "opencode:" + str(session_id)
sessions.add(session_key) sessions.add(session_key)
active_days.add(day) active_days.add(day)
prompts += 1 prompts += 1
@@ -800,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 # CLI's login. Nothing else leaves the credential store: the token goes
# nowhere but the Authorization header of the limits probe, and only the # nowhere but the Authorization header of the limits probe, and only the
# plan label may travel into the printed record. # 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: try:
data = json.loads((claude_dir / ".credentials.json").read_text(encoding="utf-8")) data = json.loads((claude_dir / ".credentials.json").read_text(encoding="utf-8"))
except Exception: except Exception:
return "", 0, 0, "" return "", 0, ""
login = data.get("claudeAiOauth") login = data.get("claudeAiOauth")
if not isinstance(login, dict): if not isinstance(login, dict):
return "", 0, 0, "" return "", 0, ""
# The profile's tier is asked first, but only one that names a multiplier plan = plan_label(str(login.get("rateLimitTier") or ""), str(login.get("subscriptionType") or ""))
# outranks the credentials'. The subscription still leads the label, so a return str(login.get("accessToken") or ""), number(login.get("expiresAt")), plan
# 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,
)
# 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: def plan_label(tier: str, subscription: str) -> str:
plan = subscription[0].upper() + subscription[1:] if subscription else "" if tier:
match = re.search(r"max_(\d+x)", tier, re.IGNORECASE) if tier else None match = re.search(r"max_(\d+x)", tier, re.IGNORECASE)
if not match: if match:
return plan return "Max " + match.group(1)
return (plan or "Max") + " " + match.group(1) if subscription:
return subscription[0].upper() + subscription[1:]
return ""
def parse_utilization(value: Any) -> float: def parse_utilization(value: Any) -> float:
@@ -1031,8 +765,11 @@ def probe_limits(access_token: str) -> dict[str, Any]:
return {"ok": True, "limits": limits} return {"ok": True, "limits": limits}
# Cached windows reset to zero when their measured period ends. Without a # A cached percentage outlives the probe that measured it, but only until its
# fresh probe, the next rolling window has no known reset time yet. # 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: def limit_window_open(entry: dict[str, Any], now: dt.datetime) -> bool:
raw = str(entry.get("resetsAt") or "") raw = str(entry.get("resetsAt") or "")
if raw == "": if raw == "":
@@ -1051,54 +788,30 @@ def usable_cached_limits(cached: dict[str, Any]) -> list[dict[str, Any]]:
if not isinstance(entries, list): if not isinstance(entries, list):
return [] return []
now = dt.datetime.now(dt.timezone.utc) now = dt.datetime.now(dt.timezone.utc)
return [ return [entry for entry in entries if isinstance(entry, dict) and limit_window_open(entry, now)]
entry if limit_window_open(entry, now) else dict(entry, percent=0.0, resetsAt="")
for entry in entries if isinstance(entry, dict)
]
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]: def collect_limits(access_token: str, expires_at_ms: int, force: bool) -> dict[str, Any]:
# `live` says the numbers came from Anthropic just now (or within the reuse result = {"limits": [], "usageStatusText": "", "authHelpText": AUTH_HELP}
# 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
# A panel that is opened and shut repeatedly must not turn into a request # 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 # 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 # kept as the answer of record when a later probe fails.
# keeps its own, so one account's allowance never stands in for another's. probe_cache = cache_root() / "claude-limits.json"
probe_cache = cache_root() / cache_name cached = read_fresh_json(probe_cache, float("inf")) or {}
cached = read_json(probe_cache) or {}
fallback = usable_cached_limits(cached) 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 # 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 # 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 # 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 # 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 == "": if access_token == "":
result["limits"] = fallback result["limits"] = fallback
result["usageStatusText"] = "Waiting for auth" result["usageStatusText"] = "Waiting for auth"
return result return result
if expires_at_ms > 0 and expires_at_ms <= time.time() * 1000: if expires_at_ms > 0 and expires_at_ms <= time.time() * 1000:
result["limits"] = fallback 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["usageStatusText"] = "Sign-in expired"
result["authHelpText"] = ( result["authHelpText"] = (
"Claude Code's saved sign-in expired" "Claude Code's saved sign-in expired"
@@ -1111,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 # entirely; the interval is there to absorb repeated panel opens, not to
# overrule someone who pressed refresh. # overrule someone who pressed refresh.
fetched_at = number(cached.get("fetchedAtMs")) / 1000 fetched_at = number(cached.get("fetchedAtMs")) / 1000
fetched_age = time.time() - fetched_at if fallback and not force and time.time() - fetched_at < PROBE_MIN_INTERVAL_SECONDS:
if fallback and not force and 0 <= fetched_age < PROBE_MIN_INTERVAL_SECONDS:
result["limits"] = fallback result["limits"] = fallback
result["live"] = True
result["authHelpText"] = ""
return result return result
probe = probe_limits(access_token) probe = probe_limits(access_token)
if probe["ok"]: if probe["ok"]:
result["limits"] = probe["limits"] result["limits"] = probe["limits"]
result["live"] = True write_json(probe_cache, {"fetchedAtMs": round(time.time() * 1000), "limits": probe["limits"]})
result["authHelpText"] = ""
result["fetchedAtMs"] = round(time.time() * 1000)
write_json(probe_cache, {"fetchedAtMs": result["fetchedAtMs"], "limits": probe["limits"]})
return result return result
# The first probe after login often fires before DHCP has handed out a # The first probe after login often fires before DHCP has handed out a
@@ -1133,95 +840,25 @@ def collect_limits(access_token: str, expires_at_ms: int, refresh_expires_at_ms:
result["retryAdvised"] = True result["retryAdvised"] = True
if fallback: if fallback:
result["limits"] = fallback result["limits"] = fallback
fetched_age = time.time() - result["fetchedAtMs"] / 1000
result["live"] = 0 <= fetched_age < CURRENT_ENOUGH_SECONDS
else: else:
result["usageStatusText"] = "Claude limits unavailable" result["usageStatusText"] = "Claude limits unavailable"
result["authHelpText"] = probe["helpText"] result["authHelpText"] = probe["helpText"]
return result 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 # -------------------------------------------------------------------- record
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser() 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("--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) parser.add_argument("--cache-seconds", type=float, default=20)
args = parser.parse_args() args = parser.parse_args()
claude_dir = config_dir() claude_dir = config_dir()
scan_age = 0 if args.force else (900 if args.limits_only else args.cache_seconds) 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: if number(stats.get("totalPrompts")) <= 0:
fallback = stats_cache_fallback(claude_dir) fallback = stats_cache_fallback(claude_dir)
@@ -1242,19 +879,8 @@ def main() -> int:
if opencode is not None: if opencode is not None:
stats = merge_stats(stats, opencode) stats = merge_stats(stats, opencode)
registered = registered_accounts() access_token, expires_at_ms, plan = oauth_login(claude_dir)
accounts = [account_limits(account, args.force) for account in registered] limits = collect_limits(access_token, expires_at_ms, args.force)
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"]
record = { record = {
"schemaVersion": 1, "schemaVersion": 1,
@@ -1267,16 +893,9 @@ def main() -> int:
"usageStatusText": limits["usageStatusText"], "usageStatusText": limits["usageStatusText"],
"authHelpText": limits["authHelpText"], "authHelpText": limits["authHelpText"],
"limits": limits["limits"], "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"): if limits.get("retryAdvised"):
record["retryAdvised"] = True record["retryAdvised"] = True
if accounts:
record["accountSwitch"] = registered[0]["switch"]
record["accounts"] = accounts
record.update(stats) record.update(stats)
print(json.dumps(record, separators=(",", ":"), sort_keys=True)) print(json.dumps(record, separators=(",", ":"), sort_keys=True))
return 0 return 0
+109 -583
View File
@@ -22,7 +22,6 @@ import subprocess
import sys import sys
import tempfile import tempfile
import time import time
import tomllib
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
@@ -30,11 +29,6 @@ AGENT_ID = "codex"
AGENT_NAME = "Codex" AGENT_NAME = "Codex"
AUTH_HELP = "Run `codex login` to authenticate." 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 # 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 # update command backgrounds one per agent while the panel refreshes on its
# own); every periodic widget refresh lands a real rescan, however low # own); every periodic widget refresh lands a real rescan, however low
@@ -43,11 +37,6 @@ NATIVE_PROVIDER = "openai"
SCAN_REUSE_SECONDS = 20 SCAN_REUSE_SECONDS = 20
LIMITS_ONLY_REUSE_SECONDS = 900 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): def local_day(value):
if value is None: if value is None:
@@ -102,52 +91,6 @@ def find_command(name):
return shutil.which(name, path=ENV.get("PATH")) 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() now = datetime.now()
today = now.strftime("%Y-%m-%d") today = now.strftime("%Y-%m-%d")
recent_dates = [(now - timedelta(days=offset)).strftime("%Y-%m-%d") for offset in range(6, -1, -1)] 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() 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): def add_usage(day, session_key, model, input_tokens, output_tokens, cache_read, cache_write):
record = {"days": {}} global today_prompts, today_total_tokens, total_prompts
record_usage(record, day, model, input_tokens, output_tokens, cache_read, cache_write) total = input_tokens + output_tokens + cache_read + cache_write
merge_file_record(session_key, record) 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): if day in recent:
try: recent[day]["messageCount"] += total
with open(path, encoding="utf-8", errors="replace") as session_file:
header = json.loads(session_file.readline())
except (OSError, json.JSONDecodeError):
return None
if not isinstance(header, dict): if day == today:
return None today_prompts += 1
parent = header.get("parentSession") today_sessions.add(session_key)
if not isinstance(parent, str) or not parent: today_total_tokens += total
return None today_tokens_by_model[model] = today_tokens_by_model.get(model, 0) + total
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
def scan_pi_sessions(): 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" rg = find_command("rg") or "rg"
session_keys = {}
today_session_keys = {}
for root in roots: for root in roots:
if not root.exists(): if not root.exists():
continue continue
try: try:
proc = subprocess.Popen( proc = subprocess.Popen(
# --no-ignore: these are data files, not a source tree. When $HOME is [rg, "--json", "-e", r'"provider"\s*:\s*"openai-codex"', "-e", r'"api"\s*:\s*"openai-codex', str(root)],
# 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)],
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
text=True, text=True,
@@ -283,22 +163,16 @@ def scan_pi_sessions():
if event.get("type") != "match": if event.get("type") != "match":
continue continue
line = event.get("data", {}).get("lines", {}).get("text", "") line = event.get("data", {}).get("lines", {}).get("text", "")
raw_path = event.get("data", {}).get("path", {}).get("text", "pi-session") path = event.get("data", {}).get("path", {}).get("text", "pi-session")
path = os.path.abspath(os.path.expanduser(raw_path))
entry = json.loads(line) entry = json.loads(line)
except Exception: except Exception:
continue continue
if entry.get("type") != "message": if entry.get("type") != "message":
continue continue
message_id = str(entry.get("id") or "") message_key = path + ":" + str(entry.get("id") or "")
message_timestamp = str(entry.get("timestamp") or "") if message_key in seen_pi_messages:
# Forks retain both fields; IDs alone can collide across sessions. continue
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
seen_pi_messages.add(message_key) seen_pi_messages.add(message_key)
message = entry.get("message") or {} message = entry.get("message") or {}
if message.get("role") != "assistant": if message.get("role") != "assistant":
@@ -322,37 +196,14 @@ def scan_pi_sessions():
continue continue
day = local_day(entry.get("timestamp") or message.get("timestamp")) day = local_day(entry.get("timestamp") or message.get("timestamp"))
session_keys.setdefault(path, set()).add(message_key) session_key = path
if day == today: add_usage(day, session_key, model_name(message.get("model")), input_tokens, output_tokens, cache_read, cache_write)
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)
try: try:
proc.wait(timeout=1) proc.wait(timeout=1)
except Exception: except Exception:
proc.kill() 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(): def scan_opencode_sessions():
# A subscription burned entirely through opencode leaves no native session # A subscription burned entirely through opencode leaves no native session
@@ -432,7 +283,7 @@ def scan_opencode_sessions():
return True 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")) codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
roots = [codex_home / "sessions", codex_home / "archived_sessions"] roots = [codex_home / "sessions", codex_home / "archived_sessions"]
files = [] files = []
@@ -442,98 +293,48 @@ def scan_native_codex_sessions(cached_files, scanned_files):
continue continue
for path in root.rglob("*.jsonl"): for path in root.rglob("*.jsonl"):
try: try:
stat_result = path.stat() if path.stat().st_mtime >= cutoff:
files.append(path)
except OSError: except OSError:
continue pass
if stat_result.st_mtime >= cutoff:
files.append((path, stat_result))
for path, stat_result in files: for path in files:
key = str(path) current_model = "codex"
record = reusable_record(cached_files, key, stat_result) try:
if record is None: with path.open(errors="replace") as handle:
record = read_native_codex_session(path, stat_result) for raw in handle:
if record["mtime"] is not None: try:
scanned_files[key] = record entry = json.loads(raw)
merge_file_record(key, record) except Exception:
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:
continue continue
seen_meta = True if entry.get("type") == "turn_context":
# Codex CLI happily fronts any OpenAI-compatible backend (`--oss`, payload = entry.get("payload") or {}
# or a custom model_provider in config.toml). Those turns bill the current_model = model_name(payload.get("model") or payload.get("model_slug") or current_model)
# 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:
continue continue
previous_total_usage = total_usage payload = entry.get("payload") or entry
day = local_day(entry.get("timestamp") or stat_result.st_mtime) if entry.get("type") == "response_item" and isinstance(payload, dict):
record_usage(record, day, current_model, input_tokens, output_tokens, cache_read, cache_write) payload = payload.get("payload") or payload
except Exception: if not isinstance(payload, dict):
# Keep what was read before the failure, but caching a partial record would continue
# hide the rest until the file changes, so hand back one the cache refuses. if payload.get("type") != "token_count":
record["mtime"] = None continue
return record 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(): def cache_root():
@@ -542,86 +343,16 @@ def cache_root():
return root return root
def scan_cache_digest(): def scan_cache_paths():
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex")) 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" 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 # 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. # 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] digest = 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()
root = cache_root() root = cache_root()
return root / f"codex-scan-{digest}.json", root / f"codex-scan-{digest}.lock" 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): def read_fresh_json(path, max_age_seconds):
if max_age_seconds <= 0 or not path.exists(): if max_age_seconds <= 0 or not path.exists():
return None 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 # 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 # corrupted or foreign-shaped file is a cache miss (rescan + rewrite) instead
# of a crash or a garbage record. # 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): def read_cached_stats(cache_file, max_age_seconds):
cached = read_fresh_json(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 return None
# today* fields only mean "today" on the day they were scanned. A cache # 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, # 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): def write_cached_stats(cache_file, stats):
try: 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: except Exception as exc:
print(f"omarchy-agent-usage-codex: could not write usage cache ({exc})", file=sys.stderr) 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():
def run_local_scans(max_age):
cached_files = read_file_cache() if max_age > 0 else {}
scanned_files = {}
scan_pi_sessions() scan_pi_sessions()
scan_native_codex_sessions(cached_files, scanned_files) scan_native_codex_sessions()
complete = scan_opencode_sessions() complete = scan_opencode_sessions()
write_file_cache(scanned_files)
return local_stats(), complete return local_stats(), complete
@@ -724,7 +450,7 @@ def cached_local_stats(max_age):
return _cached_local_stats(max_age) return _cached_local_stats(max_age)
except Exception as exc: except Exception as exc:
print(f"omarchy-agent-usage-codex: cache unavailable ({exc}); scanning directly", file=sys.stderr) 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 return stats
@@ -740,7 +466,7 @@ def _cached_local_stats(max_age):
cached = read_cached_stats(cache_file, max_age) cached = read_cached_stats(cache_file, max_age)
if cached is not None: if cached is not None:
return cached 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 # An interrupted scan still serves this run, but caching it would
# suppress the missing usage for every reader until the cache expires. # suppress the missing usage for every reader until the cache expires.
if complete: if complete:
@@ -748,55 +474,25 @@ def _cached_local_stats(max_age):
return stats 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): def rpc_request(proc, request_id, method, params=None, timeout=8):
payload = {"id": request_id, "method": method, "params": params or {}} payload = {"id": request_id, "method": method, "params": params or {}}
rpc_send(proc, payload, method) proc.stdin.write(json.dumps(payload) + "\n")
# Read the raw fd and split lines ourselves. The app-server can emit proc.stdin.flush()
# notifications in the same write as a reply; a buffered readline() would deadline = time.time() + timeout
# pull them all into Python's buffer where select() cannot see them, and while time.time() < deadline:
# the reply would sit there until the deadline. ready, _, _ = select.select([proc.stdout], [], [], 0.25)
fd = proc.stdout.fileno() if not ready:
pending = getattr(proc, "_rpc_pending", b"") continue
deadline = time.monotonic() + timeout line = proc.stdout.readline()
try: if not line:
while True: break
while b"\n" in pending: try:
line, pending = pending.split(b"\n", 1) message = json.loads(line)
try: except Exception:
message = json.loads(line) continue
except Exception: if message.get("id") == request_id:
continue return message
if isinstance(message, dict) and message.get("id") == request_id: raise TimeoutError(method)
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}")
def limit_window(window): def limit_window(window):
@@ -822,153 +518,47 @@ def limit_window(window):
} }
def codex_rpc_help(proc, stderr_file, exc): def fetch_codex_rpc():
"""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):
result = {"limits": [], "tierLabel": "", "usageStatusText": "", "authHelpText": AUTH_HELP} result = {"limits": [], "tierLabel": "", "usageStatusText": "", "authHelpText": AUTH_HELP}
env = dict(ENV, CODEX_HOME=str(home)) if home else ENV codex = find_command("codex")
codex = find_codex_binary()
if not codex: if not codex:
result["usageStatusText"] = "Codex unavailable" result["usageStatusText"] = "Codex unavailable"
result["authHelpText"] = "codex not found in PATH" result["authHelpText"] = "codex not found in PATH"
return result 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: try:
proc = subprocess.Popen( proc = subprocess.Popen(
[codex, "-s", "read-only", "-a", "on-request", "app-server"], [codex, "-s", "read-only", "-a", "on-request", "app-server"],
stdin=subprocess.PIPE, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=stderr_file or subprocess.DEVNULL, stderr=subprocess.DEVNULL,
text=True, text=True,
env=env, env=ENV,
) )
except Exception as exc: except Exception as exc:
if stderr_file:
stderr_file.close()
result["usageStatusText"] = "Codex unavailable" result["usageStatusText"] = "Codex unavailable"
result["authHelpText"] = str(exc) result["authHelpText"] = str(exc)
return result return result
try: try:
rpc_request(proc, 1, "initialize", {"clientInfo": {"name": "omarchy-agent-usage", "version": "1"}}, timeout=8) rpc_request(proc, 1, "initialize", {"clientInfo": {"name": "omarchy-agent-usage", "version": "1"}}, timeout=8)
rpc_send(proc, {"method": "initialized", "params": {}}, "initialized") proc.stdin.write(json.dumps({"method": "initialized", "params": {}}) + "\n")
limits_msg = rpc_request(proc, 2, "account/rateLimits/read", timeout=8) 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. account = (account_msg.get("result") or {}).get("account") or {}
# 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
limits = (limits_msg.get("result") or {}).get("rateLimits") or {} limits = (limits_msg.get("result") or {}).get("rateLimits") or {}
result["authHelpText"] = "" plan = limits.get("planType") or account.get("planType") or account.get("type") or ""
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
result["tierLabel"] = str(plan) if plan else "" result["tierLabel"] = str(plan) if plan else ""
for window in (limits.get("primary"), limits.get("secondary")): for window in (limits.get("primary"), limits.get("secondary")):
entry = limit_window(window) entry = limit_window(window)
if entry: if entry:
result["limits"].append(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: except Exception as exc:
result["usageStatusText"] = "Codex limits unavailable" result["usageStatusText"] = "Codex limits unavailable"
result["authHelpText"] = codex_rpc_help(proc, stderr_file, exc) result["authHelpText"] = str(exc)
finally: finally:
try: try:
proc.terminate() proc.terminate()
@@ -978,56 +568,9 @@ def fetch_codex_rpc(home=None):
proc.kill() proc.kill()
except Exception: except Exception:
pass pass
if stderr_file:
stderr_file.close()
return result 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(): def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
# --force rescans everything and rewrites the cache. --limits-only is kept # --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) 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) stats = cached_local_stats(max_age)
registered = registered_accounts() rpc = fetch_codex_rpc()
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()
record = { record = {
"schemaVersion": 1, "schemaVersion": 1,
@@ -1067,9 +596,6 @@ def main():
} }
record.update(stats) record.update(stats)
record.update(rpc) record.update(rpc)
if accounts:
record["accountSwitch"] = registered[0]["switch"]
record["accounts"] = accounts
print(json.dumps(record, separators=(",", ":"))) print(json.dumps(record, separators=(",", ":")))
+3 -50
View File
@@ -29,7 +29,7 @@ from typing import Any
AGENT_ID = "fireworks" AGENT_ID = "fireworks"
AGENT_NAME = "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" 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 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: def opencode_auth_path() -> Path:
data_home = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share")) data_home = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share"))
return data_home / "opencode" / "auth.json" 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: def read_opencode_key(path: Path) -> str:
try: try:
parsed = json.loads(path.read_text()) 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]: def credentials(auth_path: Path, config: dict[str, Any]) -> tuple[str, str]:
file_key, file_account = read_auth_file(auth_path) file_key, file_account = read_auth_file(auth_path)
# Agent logins are the fallback ladder below an explicit key or a firectl # opencode is the last resort: an explicit key or a firectl login should
# login: pi first as Omarchy's default agent, opencode as the last resort. # win over whatever another tool happens to be signed in with.
api_key = ( api_key = (
str(os.environ.get("FIREWORKS_API_KEY", "")).strip() str(os.environ.get("FIREWORKS_API_KEY", "")).strip()
or file_key or file_key
or read_pi_key(pi_auth_path())
or read_opencode_key(opencode_auth_path()) or read_opencode_key(opencode_auth_path())
) )
account_id = ( account_id = (
-361
View File
@@ -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()
-21
View File
@@ -51,13 +51,6 @@ collect() {
mv "$tmp" "$USAGE_DIR/$agent.json" 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=() pids=()
for collector in "$OMARCHY_PATH"/bin/omarchy-agent-usage-*; do for collector in "$OMARCHY_PATH"/bin/omarchy-agent-usage-*; do
[[ -x $collector ]] || continue [[ -x $collector ]] || continue
@@ -72,18 +65,4 @@ status=0
for pid in "${pids[@]}"; do for pid in "${pids[@]}"; do
wait "$pid" || status=1 wait "$pid" || status=1
done 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 exit $status
+2 -34
View File
@@ -6,12 +6,6 @@
set -e 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:-}} target_user=${OMARCHY_INSTALL_USER:-${SUDO_USER:-}}
if [[ -z $target_user && -n ${PKEXEC_UID:-} ]]; then if [[ -z $target_user && -n ${PKEXEC_UID:-} ]]; then
target_user=$(getent passwd "$PKEXEC_UID" | cut -d: -f1) target_user=$(getent passwd "$PKEXEC_UID" | cut -d: -f1)
@@ -40,41 +34,15 @@ auth required pam_faillock.so authsucc
account include system-local-login account include system-local-login
EOF EOF
# Install resume recovery alongside fingerprint PAM, so setup and upgrades if omarchy-cmd-present fprintd-list && fprintd-list "$target_user" 2>/dev/null | grep -qi finger; then
# cannot leave one configured without the other.
resume_hook_src="$OMARCHY_PATH/default/systemd/system-sleep/fprintd-resume"
resume_hook_dst=/usr/lib/systemd/system-sleep/fprintd-resume
stop_timeout_src="$OMARCHY_PATH/default/systemd/system/fprintd.service.d/10-stop-timeout.conf"
stop_timeout_dst=/etc/systemd/system/fprintd.service.d/10-stop-timeout.conf
# Keep the privileged probe pinned to /usr/bin; failure is not empty enrollment.
if [[ -x /usr/bin/fprintd-list ]]; then
fingerprint_status=$(/usr/bin/fprintd-list "$target_user" 2>&1 || true)
else
fingerprint_status="no"
fi
if grep -qE '^[[:space:]]*-[[:space:]]*#[0-9]+:' <<< "$fingerprint_status"; then
echo "Configuring lock screen fingerprint authentication..." 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' as_root tee /etc/pam.d/omarchy-lock-fingerprint >/dev/null <<'EOF'
#%PAM-1.0 #%PAM-1.0
auth required pam_fprintd.so auth required pam_fprintd.so
account include system-local-login account include system-local-login
EOF 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 else
echo "Could not check fingerprint enrollment; keeping the existing configuration." >&2 as_root rm -f /etc/pam.d/omarchy-lock-fingerprint
fi fi
# omarchy-shell can't reach a running shell during chroot install. The echo # omarchy-shell can't reach a running shell during chroot install. The echo
-77
View File
@@ -1,77 +0,0 @@
#!/bin/bash
# omarchy:summary=Restore channel and regional pacman defaults in an installed target
# omarchy:args=<stable|rc|edge> [target-root] | --render <stable|rc|edge> <output-dir> [target-root]
# omarchy:requires-sudo=true
# omarchy:hidden=true
set -euo pipefail
usage() {
echo "Usage: omarchy-apply-pacman <stable|rc|edge> [target-root]" >&2
echo " omarchy-apply-pacman --render <stable|rc|edge> <output-dir> [target-root]" >&2
exit 1
}
# --render only builds the two files, so an unprivileged caller can hand root
# nothing but plain copies of the finished result.
render_dir=""
if [[ ${1:-} == "--render" ]]; then
(( $# == 3 || $# == 4 )) || usage
channel="$2"
render_dir="$3"
[[ -d $render_dir ]] || { echo "Render directory not found: $render_dir" >&2; exit 1; }
etc="${4:-/}/etc"
else
(( $# >= 1 && $# <= 2 )) || usage
channel="$1"
etc="${2:-/}/etc"
fi
case "$channel" in
stable|rc|edge) ;;
*) echo "Invalid pacman channel: $channel" >&2; exit 1 ;;
esac
# The ISO writes this plain-text marker before target finalization. Missing
# markers keep existing installations global; never source it as shell code.
region=global
if [[ -e $etc/omarchy/region ]]; then
region=$(<"$etc/omarchy/region")
fi
case "$region" in
global|cn) ;;
*) echo "Unsupported region: $region (expected global or cn)" >&2; exit 1 ;;
esac
# Build both files before replacing either. Explicit refreshes retain backups;
# ordinary updates do not call this command or enforce regional defaults.
if [[ -n $render_dir ]]; then
staging="$render_dir"
else
staging=$(mktemp -d)
trap 'rm -rf "$staging"' EXIT
fi
cp "$OMARCHY_PATH/default/pacman/pacman-$channel.conf" "$staging/pacman.conf"
if [[ $region == "global" ]]; then
cp "$OMARCHY_PATH/default/pacman/mirrorlist-$channel" "$staging/mirrorlist"
else
profile="$OMARCHY_PATH/default/regions/$region/pacman"
printf '\n' >> "$staging/pacman.conf"
cat "$profile/pacman.conf.append" >> "$staging/pacman.conf"
# pacman tries servers in order, so the regional mirror leads and the
# channel's own mirror stays as the fallback.
cat "$profile/mirrorlist-$channel.prepend" "$OMARCHY_PATH/default/pacman/mirrorlist-$channel" > "$staging/mirrorlist"
fi
if [[ -z $render_dir ]]; then
mkdir -p "$etc/pacman.d"
for file in pacman.conf pacman.d/mirrorlist; do
if [[ -e $etc/$file ]]; then
cp -f "$etc/$file" "$etc/$file.bak"
fi
done
# Copying over an existing file keeps its owner and mode, as refresh always has.
cp -f "$staging/pacman.conf" "$etc/pacman.conf"
cp -f "$staging/mirrorlist" "$etc/pacman.d/mirrorlist"
fi
+2 -43
View File
@@ -29,25 +29,16 @@ fi
# stream down to the sink underneath. # stream down to the sink underneath.
downstream="$(pactl list sink-inputs 2>/dev/null | downstream="$(pactl list sink-inputs 2>/dev/null |
awk -v virt="$sink" ' awk -v virt="$sink" '
BEGIN {
output = virt
sub(/^effect_input\./, "effect_output.", output)
}
/^Sink Input #/ {target = ""} /^Sink Input #/ {target = ""}
/^[[:space:]]*Sink:/ {target = $2} /^[[:space:]]*Sink:/ {target = $2}
/node\.name = / { /node\.name = / {
name = $0 name = $0
sub(/.*node\.name = "/, "", name) sub(/.*node\.name = "/, "", name)
sub(/"$/, "", name) sub(/"$/, "", name)
if (target != "" && (name == output || (output == virt && index(name, virt) == 1))) {selected = target; exit} if (index(name, virt) == 1 && target != "") {print target; exit}
if (target != "" && fallback == "" && index(name, virt) == 1) fallback = target
} }
/application\.name = "EasyEffects"/ { /application\.name = "EasyEffects"/ {
if (virt == "easyeffects_sink" && target != "") {selected = target; exit} if (virt == "easyeffects_sink" && target != "") {print target; exit}
}
END {
if (selected != "") print selected
else if (fallback != "") print fallback
}')" }')"
if [[ -n $downstream ]]; then if [[ -n $downstream ]]; then
@@ -59,38 +50,6 @@ if [[ -n $downstream ]]; then
fi fi
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. # 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. # Fall back to the sink itself so callers still have something to act on.
printf '%s\n' "$sink" printf '%s\n' "$sink"
+1 -4
View File
@@ -109,10 +109,7 @@ right)
;; ;;
esac esac
# Sample the first frame only. Without the selector a video background makes pixel=$(magick "$background_path" -auto-orient \
# 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 \
-resize "${screen_width}x${screen_height}^" \ -resize "${screen_width}x${screen_height}^" \
-gravity center -extent "${screen_width}x${screen_height}" \ -gravity center -extent "${screen_width}x${screen_height}" \
-gravity NorthWest -crop "$crop" +repage \ -gravity NorthWest -crop "$crop" +repage \
+5 -20
View File
@@ -15,9 +15,8 @@
# start. # start.
# #
# Env: OMARCHY_SCREENRECORD_DEBUG=true appends gpu-screen-recorder's stderr (and # Env: OMARCHY_SCREENRECORD_DEBUG=true appends gpu-screen-recorder's stderr (and
# the picker target it was launched with) to # the picker target it was launched with) to /tmp/omarchy-screenrecord.log so
# $XDG_RUNTIME_DIR/omarchy-screenrecord.log so users can attach a log when # users can attach a log when reporting capture failures.
# reporting capture failures.
[[ -f ~/.config/user-dirs.dirs ]] && source ~/.config/user-dirs.dirs [[ -f ~/.config/user-dirs.dirs ]] && source ~/.config/user-dirs.dirs
OUTPUT_DIR="${OMARCHY_SCREENRECORD_DIR:-${XDG_VIDEOS_DIR:-$HOME/Videos}}" OUTPUT_DIR="${OMARCHY_SCREENRECORD_DIR:-${XDG_VIDEOS_DIR:-$HOME/Videos}}"
@@ -35,18 +34,9 @@ WEBCAM_SIZE="medium"
RESOLUTION="" RESOLUTION=""
FULLSCREEN="false" FULLSCREEN="false"
STOP_RECORDING="false" STOP_RECORDING="false"
# Both of these live in the per-user runtime directory, which is 0700. What RECORDING_FILE="/tmp/omarchy-screenrecord-filename"
# RECORDING_FILE holds is read back on stop and used as a path -- ffmpeg writes REGION_FILE="${XDG_RUNTIME_DIR:-/tmp}/omarchy-screenrecord-region"
# beside it, `mv` replaces it, and `rm -f` deletes its preview -- so a name in LOG_FILE=$([[ ${OMARCHY_SCREENRECORD_DEBUG:-false} == "true" ]] && echo "/tmp/omarchy-screenrecord.log" || echo "/dev/null")
# 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"
LOG_FILE=$([[ ${OMARCHY_SCREENRECORD_DEBUG:-false} == "true" ]] && echo "$RUNTIME_DIR/omarchy-screenrecord.log" || echo "/dev/null")
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
@@ -154,11 +144,6 @@ select_capture_target() {
} }
start_screenrecording() { start_screenrecording() {
mkdir -p "$RUNTIME_DIR" || return 1
# XDG_STATE_HOME may be outside a private home; protect existing fallbacks too.
if [[ -z ${XDG_RUNTIME_DIR:-} ]]; then
chmod 700 "$RUNTIME_DIR" || return 1
fi
local capture_args=() local capture_args=()
local target local target
+72 -13
View File
@@ -2,22 +2,81 @@
# omarchy:summary=Take a screenshot # omarchy:summary=Take a screenshot
# omarchy:group=capture # 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:examples=omarchy screenshot | omarchy capture screenshot region
# omarchy:aliases=omarchy screenshot # omarchy:aliases=omarchy screenshot
if [[ -n ${OMARCHY_SCREENSHOT_DIR:-} && -z ${OMASNAP_SCREENSHOT_DIR:-} ]]; then [[ -f ~/.config/user-dirs.dirs ]] && source ~/.config/user-dirs.dirs
export OMASNAP_SCREENSHOT_DIR="$OMARCHY_SCREENSHOT_DIR" 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 fi
omasnap_args=() pkill slurp && exit 0
for arg in "$@"; do
case "$arg" in
copy) omasnap_args+=(--copy) ;;
save) omasnap_args+=(--save) ;;
slurp) ;;
*) omasnap_args+=("$arg") ;;
esac
done
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
+1 -4
View File
@@ -8,10 +8,7 @@
set -euo pipefail set -euo pipefail
readonly MARGIN=40 readonly MARGIN=40
# Has to resolve to whatever omarchy-capture-screenrecording writes, fallback readonly REGION_FILE="${XDG_RUNTIME_DIR:-/tmp}/omarchy-screenrecord-region"
# 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"
usage() { usage() {
echo "Usage: omarchy-capture-webcam-resize <smaller|larger|reset|small|medium|large>" >&2 echo "Usage: omarchy-capture-webcam-resize <smaller|larger|reset|small|medium|large>" >&2
+7 -80
View File
@@ -1,25 +1,10 @@
#!/bin/bash -p #!/bin/bash
# omarchy:summary=Set the Omarchy package channel. # omarchy:summary=Set the Omarchy package channel.
# omarchy:args=<stable|rc|edge|dev> # omarchy:args=<stable|rc|edge|dev>
# omarchy:requires-sudo=true # 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 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]"; } usage() { echo "Usage: omarchy-channel-set [stable|rc|edge|dev]"; }
fail() { echo "Error: $*" >&2; exit 1; } fail() { echo "Error: $*" >&2; exit 1; }
@@ -47,63 +32,13 @@ validate_dev_checkout() {
fi fi
} }
# The dev channel refreshes pacman with the checkout's own tools. A checkout
# without this machine's region profile would silently drop its repositories.
validate_dev_region() {
local checkout="$1" region_file="${OMARCHY_REGION_FILE:-/etc/omarchy/region}" region=global
[[ -f $region_file ]] && region=$(<"$region_file")
[[ $region == "global" ]] && return 0
# The refresh after linking needs a runnable helper and every fragment, not
# just a directory with the region's name: dev refreshes edge, and leaving
# dev later refreshes stable or rc with this checkout's tools.
local profile="$checkout/default/regions/$region/pacman" fragment supported=true
[[ -x $checkout/bin/omarchy-apply-pacman ]] || supported=false
for fragment in pacman.conf.append mirrorlist-stable.prepend mirrorlist-rc.prepend mirrorlist-edge.prepend; do
[[ -r $profile/$fragment ]] || supported=false
done
if ! $supported; then
fail "$checkout does not support the '$region' region, so switching would drop its package repositories. Check out a branch with default/regions/$region, or remove $region_file to use global defaults."
fi
}
link_dev_checkout() { link_dev_checkout() {
local checkout="$1" required local checkout="$1"
[[ -d $checkout/.git ]] || git clone https://github.com/omacom/omarchy.git "$checkout" [[ -d $checkout/.git ]] || git clone https://github.com/basecamp/omarchy.git "$checkout"
validate_dev_region "$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
omarchy-dev-link "$checkout" --no-reboot 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; } (( $# > 0 )) || { usage; exit 1; }
dev_checkout="" dev_checkout=""
@@ -137,10 +72,6 @@ case "$channel" in
;; ;;
esac esac
# 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 if [[ -z $dev_checkout && $OMARCHY_PATH != "/usr/share/omarchy" ]]; then
leaving_dev=1 leaving_dev=1
fi fi
@@ -148,16 +79,13 @@ fi
if [[ -n $dev_checkout ]]; then if [[ -n $dev_checkout ]]; then
link_dev_checkout "$dev_checkout" link_dev_checkout "$dev_checkout"
export OMARCHY_PATH="$dev_checkout" export OMARCHY_PATH="$dev_checkout"
omarchy_security_enable_no_update_sudo export PATH="$OMARCHY_PATH/bin:$PATH"
omarchy-state set reboot-required omarchy-state set reboot-required
fi fi
OMARCHY_UPDATE_USER_PATH="$user_path" omarchy-refresh-pacman "$pacman_channel" omarchy-refresh-pacman "$pacman_channel"
# Each transaction can have replaced this tree; check before the next sudo.
wrapper_present || stop_for_older_destination
# --ask 4 accepts omarchy <-> omarchy-dev replacement prompts without file overwrites. # --ask 4 accepts omarchy <-> omarchy-dev replacement prompts without file overwrites.
omarchy-update-pacman -S --needed --noconfirm --ask 4 "${packages[@]}" sudo env OMARCHY_UPDATE_PACMAN=1 pacman -S --needed --noconfirm --ask 4 "${packages[@]}"
wrapper_present || stop_for_older_destination
if [[ -z $dev_checkout ]]; then if [[ -z $dev_checkout ]]; then
omarchy-dev-unlink --no-reboot omarchy-dev-unlink --no-reboot
@@ -166,7 +94,6 @@ if [[ -z $dev_checkout ]]; then
if (( leaving_dev )); then if (( leaving_dev )); then
omarchy-state set reboot-required omarchy-state set reboot-required
fi fi
omarchy_security_enable_no_update_sudo 2>/dev/null || stop_for_older_destination
fi fi
OMARCHY_UPDATE_USER_PATH="$user_path" "$OMARCHY_PATH/bin/omarchy-update" -y omarchy-update -y
+1 -1
View File
@@ -30,7 +30,7 @@ open_image() {
local path="$1" local path="$1"
[[ -r $path ]] || exit 1 [[ -r $path ]] || exit 1
exec omasnap "$path" exec tensaku-edit "$path"
} }
open_text() { open_text() {
-48
View File
@@ -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" ]]
-58
View File
@@ -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"
+1 -1
View File
@@ -3,7 +3,7 @@
# omarchy:summary=Check whether any required commands are missing # omarchy:summary=Check whether any required commands are missing
for cmd in "$@"; do for cmd in "$@"; do
if ! command -v -- "$cmd" &>/dev/null; then if ! command -v "$cmd" &>/dev/null; then
exit 0 exit 0
fi fi
done done
+1 -1
View File
@@ -3,7 +3,7 @@
# omarchy:summary=Check whether all required commands are available # omarchy:summary=Check whether all required commands are available
for cmd in "$@"; do for cmd in "$@"; do
command -v -- "$cmd" &>/dev/null || exit 1 command -v "$cmd" &>/dev/null || exit 1
done done
exit 0 exit 0
+2 -12
View File
@@ -1,10 +1,9 @@
#!/bin/bash #!/bin/bash
# omarchy:summary=Print the current working directory of the active terminal window # omarchy:summary=Print the current working directory of the active terminal window
# omarchy:args=[terminal-pid]
# omarchy:hidden=true # 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" kitty_socket="$XDG_RUNTIME_DIR/omarchy-kitty-$terminal_pid"
cwd="" cwd=""
@@ -12,16 +11,7 @@ if [[ -S $kitty_socket ]]; then
cwd=$(kitten @ --to "unix:$kitty_socket" ls --match "state:focused" 2>/dev/null | cwd=$(kitten @ --to "unix:$kitty_socket" ls --match "state:focused" 2>/dev/null |
jq -r '.[].tabs[].windows[].cwd // empty') jq -r '.[].tabs[].windows[].cwd // empty')
else else
# The newest child, as pgrep -P | tail -n1 found it, read from the kernel's shell_pid=$(pgrep -P "$terminal_pid" | tail -n1)
# 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
if [[ -n $shell_pid ]]; then if [[ -n $shell_pid ]]; then
cwd=$(readlink -f "/proc/$shell_pid/cwd" 2>/dev/null) cwd=$(readlink -f "/proc/$shell_pid/cwd" 2>/dev/null)
+8 -44
View File
@@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
# omarchy:summary=Set and launch the default coding agent # 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 # omarchy:examples=omarchy default agent | omarchy default agent codex | omarchy default agent claude
installing=false installing=false
@@ -31,57 +31,25 @@ ori | openrouter) agent="ori"; name="Ori"; agent_package="github:OpenRouterLabs/
claude | claude-code) agent="claude"; name="Claude Code" ;; claude | claude-code) agent="claude"; name="Claude Code" ;;
codex) agent="codex"; name="Codex" ;; codex) agent="codex"; name="Codex" ;;
crush) agent="crush"; name="Crush" ;; crush) agent="crush"; name="Crush" ;;
grok) agent="grok"; name="Grok" ;; dim | dimcode | dim-agent) agent="dim"; name="Dim Agent"; agent_package="npm:dimcode" ;;
openclaw) agent="openclaw"; name="OpenClaw"; agent_installer="omarchy-install-openclaw-cli" ;; 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" ;; 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" ;; 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 exit 1
;; ;;
esac esac
agent_package=${agent_package:-$agent} agent_package=${agent_package:-$agent}
# Hermes and OpenClaw are not mise tools: each is a self-updating runtime in if [[ $installing == "false" ]] && ! mise where "$agent_package" &>/dev/null; then
# 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
exec omarchy-launch-floating-terminal-with-presentation omarchy-default-agent --install "$agent" exec omarchy-launch-floating-terminal-with-presentation omarchy-default-agent --install "$agent"
fi fi
if ! agent_install; then if ! mise use -g "$agent_package"; then
if [[ $installing == "true" && -n ${agent_installer:-} ]]; then if [[ $installing == "true" ]]; then
echo "Could not install $name" >&2
elif [[ $installing == "true" ]]; then
echo "Could not install $name with mise" >&2 echo "Could not install $name with mise" >&2
else else
echo "Could not set $name as the default coding agent" >&2 echo "Could not set $name as the default coding agent" >&2
@@ -89,10 +57,6 @@ if ! agent_install; then
exit 1 exit 1
fi fi
if [[ $agent == "claude" ]]; then
omarchy-install-chromium-claude 2>/dev/null || true
fi
mkdir -p "$(dirname "$agent_file")" mkdir -p "$(dirname "$agent_file")"
printf '%s\n' "$agent" >"$agent_file" printf '%s\n' "$agent" >"$agent_file"
+2 -5
View File
@@ -17,9 +17,6 @@ if (($# == 0)); then
read -r editor <"$editor_file" read -r editor <"$editor_file"
fi fi
# sublime-text-4 ships `subl`; older defaults stored `sublime_text`.
[[ $editor == sublime_text ]] && editor=subl
[[ -n $editor ]] && echo "$editor" || echo "nvim" [[ -n $editor ]] && echo "$editor" || echo "nvim"
exit 0 exit 0
fi fi
@@ -28,10 +25,10 @@ case "$1" in
code) selection="code"; editor="code"; name="VSCode"; glyph= ;; code) selection="code"; editor="code"; name="VSCode"; glyph= ;;
cursor) selection="cursor"; editor="cursor"; name="Cursor"; glyph= ;; cursor) selection="cursor"; editor="cursor"; name="Cursor"; glyph= ;;
zed | zeditor) selection="zed"; editor="zeditor"; name="Zed"; 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= ;; helix) selection="helix"; editor="helix"; name="Helix"; glyph= ;;
vim) selection="vim"; editor="vim"; name="Vim"; 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= ;; nvim) selection="nvim"; editor="nvim"; name="Neovim"; glyph= ;;
*) *)
echo "Usage: omarchy-default-editor <code|cursor|zed|sublime_text|helix|vim|emacs|nvim>" echo "Usage: omarchy-default-editor <code|cursor|zed|sublime_text|helix|vim|emacs|nvim>"
+4 -14
View File
@@ -129,8 +129,7 @@ term_pt_for() {
'BEGIN { printf "%d", int(s * p / b + 0.5) }' 'BEGIN { printf "%d", int(s * p / b + 0.5) }'
} }
# Set the font point size in terminal configs, creating Kitty overrides when # Set the font point size in every terminal config that exists. Family is left
# it inherits its size from the system config. Family is left
# untouched — that is omarchy-font-set's job. Live-reload signals mirror # 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. # omarchy-font-set; foot has no reload signal, so running instances are nudged.
set_terminal_size() { set_terminal_size() {
@@ -140,13 +139,8 @@ set_terminal_size() {
sed -i -E "s/^size[[:space:]]*=.*/size = $pt/" ~/.config/alacritty/alacritty.toml sed -i -E "s/^size[[:space:]]*=.*/size = $pt/" ~/.config/alacritty/alacritty.toml
fi fi
if [[ -f ~/.config/kitty/kitty.conf ]] || omarchy-cmd-present kitty; then if [[ -f ~/.config/kitty/kitty.conf ]]; then
mkdir -p ~/.config/kitty sed -i -E "s/^font_size[[:space:]]+.*/font_size $pt.0/" ~/.config/kitty/kitty.conf
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
pkill -USR1 kitty 2>/dev/null || true pkill -USR1 kitty 2>/dev/null || true
fi fi
@@ -183,13 +177,9 @@ term_current_pt() {
elif [[ -f ~/.config/alacritty/alacritty.toml ]]; then elif [[ -f ~/.config/alacritty/alacritty.toml ]]; then
grep -oP '^size[[:space:]]*=[[:space:]]*\K[0-9.]+' ~/.config/alacritty/alacritty.toml | head -1 grep -oP '^size[[:space:]]*=[[:space:]]*\K[0-9.]+' ~/.config/alacritty/alacritty.toml | head -1
elif [[ -f ~/.config/kitty/kitty.conf ]]; then elif [[ -f ~/.config/kitty/kitty.conf ]]; then
local pt grep -oP '^font_size[[:space:]]+\K[0-9.]+' ~/.config/kitty/kitty.conf | head -1
pt=$(grep -oP '^[[:space:]]*font_size[[:space:]]+\K[0-9.]+' ~/.config/kitty/kitty.conf | tail -1)
echo "${pt:-$TERM_DEFAULT_PT}"
elif [[ -f ~/.config/foot/foot.ini ]]; then elif [[ -f ~/.config/foot/foot.ini ]]; then
grep -oP ':size=\K[0-9.]+' ~/.config/foot/foot.ini | head -1 grep -oP ':size=\K[0-9.]+' ~/.config/foot/foot.ini | head -1
elif omarchy-cmd-present kitty; then
echo "$TERM_DEFAULT_PT"
fi fi
} }
+2 -8
View File
@@ -30,14 +30,8 @@ if [[ -f ~/.config/alacritty/alacritty.toml ]]; then
sed -i "s/family = \".*\"/family = \"$font_name\"/g" ~/.config/alacritty/alacritty.toml sed -i "s/family = \".*\"/family = \"$font_name\"/g" ~/.config/alacritty/alacritty.toml
fi fi
if [[ -f ~/.config/kitty/kitty.conf ]] || omarchy-cmd-present kitty; then if [[ -f ~/.config/kitty/kitty.conf ]]; then
mkdir -p ~/.config/kitty sed -i "s/^font_family .*/font_family $font_name/g" ~/.config/kitty/kitty.conf
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
pkill -USR1 kitty pkill -USR1 kitty
fi fi
+3 -38
View File
@@ -30,35 +30,6 @@ MKINITCPIO_CONF="/etc/mkinitcpio.conf.d/omarchy_resume.conf"
SWAP_FILE="/swap/swapfile" SWAP_FILE="/swap/swapfile"
RESUME_DROP_IN="/etc/limine-entry-tool.d/resume.conf" 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 # Check if hibernation is already configured
if [[ -f $MKINITCPIO_CONF ]] && grep -q "^HOOKS+=(resume)$" "$MKINITCPIO_CONF"; then if [[ -f $MKINITCPIO_CONF ]] && grep -q "^HOOKS+=(resume)$" "$MKINITCPIO_CONF"; then
# Fix empty resume_offset if btrfs map-swapfile failed during initial setup # Fix empty resume_offset if btrfs map-swapfile failed during initial setup
@@ -112,20 +83,14 @@ if ! swapon --show | grep -q "$SWAP_FILE"; then
sudo swapon -p 0 "$SWAP_FILE" sudo swapon -p 0 "$SWAP_FILE"
fi 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 # Add resume hook to mkinitcpio
sudo mkdir -p /etc/mkinitcpio.conf.d sudo mkdir -p /etc/mkinitcpio.conf.d
echo "Adding resume hook to $MKINITCPIO_CONF" echo "Adding resume hook to $MKINITCPIO_CONF"
echo "HOOKS+=(resume)" | sudo tee "$MKINITCPIO_CONF" >/dev/null 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 # 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. # hibernation image. Without these, resume happens late (after GPU drivers load) and fails.
if [[ ! -f $RESUME_DROP_IN ]]; then if [[ ! -f $RESUME_DROP_IN ]]; then
-12
View File
@@ -11,18 +11,6 @@ if (( $# < 1 )); then
fi fi
HOOK=$1 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_PATH="$HOME/.config/omarchy/hooks/$1"
HOOK_DIR="$HOOK_PATH.d" HOOK_DIR="$HOOK_PATH.d"
shift shift
+2 -13
View File
@@ -15,19 +15,8 @@ fi
HOOK_TYPE=$1 HOOK_TYPE=$1
HOOK_FILE=$2 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_DIR="$HOME/.config/omarchy/hooks/$HOOK_TYPE.d"
HOOK_NAME=$(basename -- "$HOOK_FILE") HOOK_NAME=$(basename "$HOOK_FILE")
HOOK_PATH="$HOOK_DIR/$HOOK_NAME" HOOK_PATH="$HOOK_DIR/$HOOK_NAME"
if [[ ! -f $HOOK_FILE ]]; then if [[ ! -f $HOOK_FILE ]]; then
@@ -36,7 +25,7 @@ if [[ ! -f $HOOK_FILE ]]; then
fi fi
mkdir -p "$HOOK_DIR" mkdir -p "$HOOK_DIR"
cp -- "$HOOK_FILE" "$HOOK_PATH" cp "$HOOK_FILE" "$HOOK_PATH"
chmod 755 "$HOOK_PATH" chmod 755 "$HOOK_PATH"
echo "Installed $HOOK_TYPE hook: $HOOK_PATH" echo "Installed $HOOK_TYPE hook: $HOOK_PATH"
-5
View File
@@ -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 -2
View File
@@ -3,5 +3,5 @@
# omarchy:summary=Match against the computer's DMI product name or product family (case-insensitive). # omarchy:summary=Match against the computer's DMI product name or product family (case-insensitive).
# omarchy:args=<pattern> # omarchy:args=<pattern>
grep -qi -- "$1" /sys/class/dmi/id/product_name 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 grep -qi "$1" /sys/class/dmi/id/product_family 2>/dev/null
-45
View File
@@ -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
-5
View File
@@ -1,5 +0,0 @@
#!/bin/bash
# omarchy:summary=Returns true when running in a virtual machine.
systemd-detect-virt --vm --quiet
@@ -2,18 +2,6 @@
# omarchy:summary=Toggles transparency for the currently focused window. # omarchy:summary=Toggles transparency for the currently focused window.
set -euo pipefail addr=$(hyprctl activewindow -j | jq -r '.address')
hyprctl dispatch "hl.dsp.window.set_prop({ window = \"address:$addr\", prop = \"opaque\", value = \"toggle\" })" >/dev/null 2>&1 || \
addr=$(hyprctl activewindow -j | jq -r '.address // empty') hyprctl dispatch setprop "address:$addr" opaque toggle
[[ -n $addr ]] || exit 0
window="address:$addr"
opacity=$(hyprctl getprop "$window" opacity)
inactive_opacity=$(hyprctl getprop "$window" opacity_inactive)
if [[ $opacity == "1" && $inactive_opacity == "1" ]]; then
hyprctl dispatch "hl.dsp.window.tag({ window = \"$window\", tag = \"+transparent\" })"
hyprctl dispatch "hl.dsp.window.set_prop({ window = \"$window\", prop = \"opaque\", value = \"false\" })"
else
hyprctl dispatch "hl.dsp.window.set_prop({ window = \"$window\", prop = \"opaque\", value = \"toggle\" })"
fi
-15
View File
@@ -1,15 +0,0 @@
#!/bin/bash
# omarchy:summary=Install the Claude desktop app
# omarchy:requires-sudo=true
set -euo pipefail
echo "Installing Claude..."
omarchy-pkg-add claude-desktop
echo "Opening Claude..."
setsid uwsm-app -- /usr/bin/claude-desktop >/dev/null 2>&1 &
echo ""
echo "Claude has been installed."
-20
View File
@@ -1,20 +0,0 @@
#!/bin/bash
# omarchy:summary=Install the Hermes desktop app
# omarchy:requires-sudo=true
set -e
if (( EUID == 0 )); then
echo "Run this command as your desktop user, without sudo." >&2
exit 1
fi
# The same install the default agent gets, plus opening the window.
omarchy-install-hermes-cli --now
echo "Opening Hermes Desktop..."
setsid uwsm-app -- /usr/bin/hermes-desktop >/dev/null 2>&1 &
echo ""
echo "Hermes Desktop has been installed."
-35
View File
@@ -1,35 +0,0 @@
#!/bin/bash
# omarchy:summary=Install the OpenClaw agent platform and its Control UI web app
# omarchy:requires-sudo=true
set -e
# The same self-updating install the default agent gets.
omarchy-install-openclaw-cli --now
# The desktop app is OpenClaw's Control UI: a web app served by its own
# gateway. The launcher entry goes through omarchy-launch-openclaw, which
# onboards or starts that gateway before opening the window. The icon ships
# inside the package, so nothing is fetched here.
echo "Installing the OpenClaw web app..."
omarchy-webapp-install OpenClaw "http://127.0.0.1:18789" \
/usr/share/openclaw/openclaw.png \
omarchy-launch-openclaw
# A first install runs onboarding right here: launching the app instead would
# open a second floating terminal for the wizard, identical to this one.
# omarchy-openclaw-onboard runs the wizard the way this flow needs (terminal
# prompts, gateway as a user service, and it actually returns). A machine
# that is already onboarded goes straight to the app.
if [[ -f $HOME/.openclaw/openclaw.json ]]; then
echo "Opening OpenClaw..."
setsid uwsm-app -- gtk-launch OpenClaw >/dev/null 2>&1 &
elif omarchy-openclaw-onboard && [[ -f $HOME/.openclaw/openclaw.json ]]; then
echo "Opening OpenClaw..."
setsid uwsm-app -- gtk-launch OpenClaw >/dev/null 2>&1 &
fi
echo ""
echo "OpenClaw has been installed."
echo "If you skipped onboarding, launching OpenClaw from the app grid resumes it."
-31
View File
@@ -1,31 +0,0 @@
#!/bin/bash
# omarchy:summary=Install T3 Code and point it at the Omarchy palette
# omarchy:requires-sudo=true
set -e
T3CODE_HOME="${T3CODE_HOME:-$HOME/.t3}"
T3CODE_STATE_DIR="$T3CODE_HOME/userdata"
echo "Installing T3 Code..."
omarchy-pkg-add t3code-bin
echo "Matching T3 Code to the current theme..."
mkdir -p "$T3CODE_STATE_DIR"
# An updated user's current theme may have been staged before this template existed.
if [[ ! -f $HOME/.local/state/omarchy/current/theme/t3code.json ]]; then
omarchy-theme-refresh
fi
omarchy-theme-set-t3code
# The packaged CLI selects the published palette before the app's first launch.
t3 theme set omarchy --base-dir "$T3CODE_HOME"
echo "Opening T3 Code..."
setsid uwsm-app -- gtk-launch t3code >/dev/null 2>&1 &
echo ""
echo "T3 Code has been installed."
-43
View File
@@ -1,43 +0,0 @@
#!/bin/bash
# omarchy:summary=Install the Claude extension for Chromium-based browsers
# omarchy:requires-sudo=true
set -euo pipefail
if (( EUID == 0 )); then
export PATH=/usr/bin:/bin
fi
EXTENSION_ID="fcoeoabgfenejglbffodgkkbkcdhcgfn"
EXTENSION_JSON='{ "external_update_url": "https://clients2.google.com/service/update2/crx" }'
PACKAGED_PATH=/usr/bin/omarchy-install-chromium-claude
# Brave and Brave Origin share Chromium's external extension directory.
# Seed all supported browsers, including those installed after choosing Claude.
EXTENSION_DIRS=(
/usr/share/chromium/extensions
/usr/share/google-chrome/extensions
/usr/share/microsoft-edge/extensions
)
installed=true
for dir in "${EXTENSION_DIRS[@]}"; do
if [[ ! -f $dir/$EXTENSION_ID.json ]] || [[ $(cat "$dir/$EXTENSION_ID.json") != "$EXTENSION_JSON" ]]; then
installed=false
fi
done
if [[ $installed == "true" ]]; then
exit 0
fi
if (( EUID == 0 )); then
for dir in "${EXTENSION_DIRS[@]}"; do
printf '%s\n' "$EXTENSION_JSON" | install -D -m 0644 /dev/stdin "$dir/$EXTENSION_ID.json"
done
elif [[ -t 0 ]]; then
exec sudo "$PACKAGED_PATH"
else
exec pkexec "$PACKAGED_PATH"
fi
-3
View File
@@ -16,9 +16,6 @@ browser_dirs=(
"$HOME/.config/BraveSoftware/Brave-Browser" "$HOME/.config/BraveSoftware/Brave-Browser"
"$HOME/.config/BraveSoftware/Brave-Browser-Beta" "$HOME/.config/BraveSoftware/Brave-Browser-Beta"
"$HOME/.config/BraveSoftware/Brave-Browser-Nightly" "$HOME/.config/BraveSoftware/Brave-Browser-Nightly"
"$HOME/.config/BraveSoftware/Brave-Origin"
"$HOME/.config/BraveSoftware/Brave-Origin-Beta"
"$HOME/.config/BraveSoftware/Brave-Origin-Nightly"
"$HOME/.config/microsoft-edge" "$HOME/.config/microsoft-edge"
"$HOME/.config/microsoft-edge-dev" "$HOME/.config/microsoft-edge-dev"
) )
-3
View File
@@ -17,9 +17,6 @@ browser_dirs=(
"$HOME/.config/BraveSoftware/Brave-Browser" "$HOME/.config/BraveSoftware/Brave-Browser"
"$HOME/.config/BraveSoftware/Brave-Browser-Beta" "$HOME/.config/BraveSoftware/Brave-Browser-Beta"
"$HOME/.config/BraveSoftware/Brave-Browser-Nightly" "$HOME/.config/BraveSoftware/Brave-Browser-Nightly"
"$HOME/.config/BraveSoftware/Brave-Origin"
"$HOME/.config/BraveSoftware/Brave-Origin-Beta"
"$HOME/.config/BraveSoftware/Brave-Origin-Nightly"
"$HOME/.config/microsoft-edge" "$HOME/.config/microsoft-edge"
"$HOME/.config/microsoft-edge-dev" "$HOME/.config/microsoft-edge-dev"
) )
+31 -4
View File
@@ -12,10 +12,37 @@ if [[ -z $1 ]]; then
fi fi
install_php() { install_php() {
mise tool-alias set php github:nunomaduro/static-php-builds omarchy-pkg-add php composer php-sqlite xdebug
mise use --global php@latest
mise x php -- composer global config bin-dir "$HOME/.local/bin" # Install Path for Composer
if [[ :$PATH: != *:$HOME/.config/composer/vendor/bin:* ]]; then
echo 'export PATH="$HOME/.config/composer/vendor/bin:$PATH"' >>"$HOME/.bashrc"
source "$HOME/.bashrc"
echo "Added Composer global bin directory to PATH."
else
echo "Composer global bin directory already in PATH."
fi
# Enable some extensions
local php_ini_path="/etc/php/php.ini"
local extensions_to_enable=(
"bcmath"
"intl"
"iconv"
"openssl"
"pdo_sqlite"
"pdo_mysql"
)
# Enable Xdebug
sudo sed -i \
-e 's/^;zend_extension=xdebug.so/zend_extension=xdebug.so/' \
-e 's/^;xdebug.mode=debug/xdebug.mode=debug/' \
/etc/php/conf.d/xdebug.ini
for ext in "${extensions_to_enable[@]}"; do
sudo sed -i "s/^;extension=${ext}/extension=${ext}/" "$php_ini_path"
done
} }
install_node() { install_node() {
@@ -57,7 +84,7 @@ laravel)
echo -e "Installing PHP and Laravel...\n" echo -e "Installing PHP and Laravel...\n"
install_php install_php
install_node install_node
mise x php -- composer global require laravel/installer composer global require laravel/installer
echo -e "\nYou can now run: laravel new myproject" echo -e "\nYou can now run: laravel new myproject"
;; ;;
symfony) symfony)
+1 -3
View File
@@ -27,6 +27,4 @@ elif omarchy-hw-nvidia-without-gsp; then
PACKAGES+=(lib32-nvidia-580xx-utils) PACKAGES+=(lib32-nvidia-580xx-utils)
fi fi
if (( ${#PACKAGES[@]} > 0 )); then (( ${#PACKAGES[@]} > 0 )) && omarchy-pkg-add "${PACKAGES[@]}"
omarchy-pkg-add "${PACKAGES[@]}"
fi
+1 -8
View File
@@ -6,15 +6,8 @@
set -e set -e
echo "Installing Steam..." echo "Installing Steam..."
# Install the lib32 drivers first. Steam depends on the virtual package
# lib32-vulkan-driver, and omarchy-pkg-add runs pacman with --noconfirm, which
# resolves an unsatisfied virtual dependency to the first provider without
# asking. That provider is currently lib32-nvidia-utils, so installing Steam
# ahead of the drivers pulls the NVIDIA stack onto machines that have no NVIDIA
# GPU.
omarchy-install-gaming-gpu-lib32
omarchy-pkg-add steam omarchy-pkg-add steam
omarchy-install-gaming-gpu-lib32
echo "" echo ""
echo "Steam will start automatically now. This might take a while..." echo "Steam will start automatically now. This might take a while..."
+1 -1
View File
@@ -10,7 +10,7 @@ set -e
echo "Installing Xbox controller Bluetooth support..." echo "Installing Xbox controller Bluetooth support..."
# Install xpadneo to ensure controllers work out of the box # Install xpadneo to ensure controllers work out of the box
omarchy-pkg-add xpadneo-dkms omarchy-pkg-add linux-headers xpadneo-dkms
# Prevent xpad/xpadneo driver conflict # Prevent xpad/xpadneo driver conflict
echo blacklist xpad | sudo tee /etc/modprobe.d/blacklist-xpad.conf >/dev/null echo blacklist xpad | sudo tee /etc/modprobe.d/blacklist-xpad.conf >/dev/null
-544
View File
@@ -1,544 +0,0 @@
#!/bin/bash
# omarchy:summary=Install Hermes Desktop for the default agent; its runtime provides the hermes command
# omarchy:args=<--check|--now|--retire-mise>
# omarchy:examples=omarchy install hermes cli --now | omarchy install hermes cli --check
# omarchy:requires-sudo=true
# There is one Hermes on a machine, and it is the desktop app's. hermes-desktop
# ships upstream's installer and the release it was built from, and that
# installer makes the only Hermes that can update itself: a checkout under
# ~/.hermes with its own venv, which `hermes update` fast-forwards. The mise
# build this replaced had no checkout, so nothing an update could move.
# Choosing Hermes as the default agent therefore installs the app, the way
# Install > AI does, short of opening its window; see omarchy-install-ai-hermes.
# Hermes is only ever installed through the app: --check asks whether the
# app's Hermes is in and its command is what omarchy-agent would run, and
# --now installs the app whatever else answered to hermes before, saving
# whatever held the command's name aside before upstream's installer takes it.
#
# The wrapper the retired installer wrote, and the mise environment it built,
# are known here and nowhere else: --retire-mise is what the migration runs,
# and --now runs the same removal once it has replaced the wrapper.
#
# Each mode is named outright. The app's launcher used to call this with no
# arguments to reconcile a mise copy, and a default of --now would turn every
# launch of the app into an install.
set -euo pipefail
mode=${1:-}
# Keep the runtime at the root even when invoked from a Hermes profile.
HERMES_HOME=$(realpath -ms -- "${HERMES_HOME:-$HOME/.hermes}")
home_parent=$(dirname -- "$HERMES_HOME")
if [[ ${home_parent##*/} == [Pp][Rr][Oo][Ff][Ii][Ll][Ee][Ss] ]]; then
HERMES_HOME=$(dirname -- "$home_parent")
fi
export HERMES_HOME
runtime="$HERMES_HOME/hermes-agent"
native_app="$runtime/apps/desktop/release/linux-unpacked"
command_path="$HOME/.local/bin/hermes"
# The wrapper the retired mise-backed installer wrote, matched whole so a
# wrapper that merely mentions it is not mistaken for it, and the tool it
# built. Running the wrapper built Hermes through mise, so it is never run to
# find out whether Hermes is there.
legacy_marker='# Written by omarchy-install-hermes-cli.'
legacy_tool='pipx:hermes-agent[extras=all]'
legacy_stub() {
[[ -f $command_path ]] && grep -qxF "$legacy_marker" "$command_path"
}
# Only the wrapper Omarchy wrote proves the environment is Omarchy's to remove:
# the regular file at its path, or the copy a run of this installer saved aside
# before upstream's installer took the name, since a user who chose Hermes
# before the migration ran has it there and the environment still requested.
# A link is someone else's arrangement, even when it lands on the wrapper, and
# so is a linked backup directory: the installer makes real ones.
saved_wrapper() {
[[ ! -L ${1%/hermes} && -f $1 && ! -L $1 ]] && grep -qxF "$legacy_marker" "$1"
}
legacy_owned() {
local saved
if legacy_stub && [[ ! -L $command_path ]]; then
return 0
fi
for saved in "$HOME/.local/bin"/.hermes-before-desktop.*/hermes; do
if saved_wrapper "$saved"; then
return 0
fi
done
return 1
}
# Gone means neither installed nor still asked for in the global config, where
# `mise up` would build it again. `mise rm -g` exits 0 whether or not it removed
# anything, so the listing is read instead, and a listing that cannot be read
# -- mise broken, or not there to read it -- is not an answer. The key carries
# the backend and name, with or without the options.
mise_retired() {
local requested
requested=$(mise ls -g --json 2>/dev/null) || return 1
! mise where "$legacy_tool" >/dev/null 2>&1 && ! grep -qE '"pipx:hermes-agent(\[|")' <<<"$requested"
}
# Removes the environment the retired wrapper built, judged by what is left
# rather than by what the commands claimed. Only for a caller that has proved
# it Omarchy's.
retire_mise() {
if mise_retired; then
return 0
fi
mise rm -g "$legacy_tool" >/dev/null 2>&1 || true
mise uninstall --all "$legacy_tool" >/dev/null 2>&1 || true
if mise_retired; then
return 0
fi
echo "Could not remove the Hermes that mise built. Finish by hand:" >&2
if omarchy-cmd-missing mise; then
echo " omarchy pkg add mise" >&2
fi
echo " mise rm -g '$legacy_tool'" >&2
echo " mise uninstall --all '$legacy_tool'" >&2
return 1
}
# Once the environment is gone the proof has done its work. Left behind it
# would keep --check answering no for an install that is finished, and claim
# an environment the user builds later as Omarchy's. The copies are Omarchy's
# own wrapper, so removing them takes nothing of the user's; a backup directory
# that held nothing else goes with them.
forget_legacy() {
local saved
if legacy_stub && [[ ! -L $command_path ]]; then
rm -f "$command_path"
fi
for saved in "$HOME/.local/bin"/.hermes-before-desktop.*/hermes; do
if saved_wrapper "$saved"; then
rm -f "$saved"
rmdir "${saved%/hermes}" 2>/dev/null || true
fi
done
}
# A hermes at that path is usable when it is a command that runs: a regular
# executable whose --version answers. The executable bit alone proves little -- a
# directory passes -x on search permission, and a wrapper whose interpreter or
# target is gone passes it too. The desktop app applies the same probe with the
# same 15 second budget, so what passes here is what it will use.
hermes_runs() {
[[ -f $command_path && -x $command_path ]] &&
timeout 15 "$command_path" --version >/dev/null 2>&1
}
# A flag counts only when the help defines it, not whenever it is mentioned:
# closing its short form, followed by its metavar, or padded to a description
# column. Matched against the help text rather than by parsing an actual
# invocation on purpose: a release that ignores unknown arguments would turn
# the probe into a live session.
help_defines_flag() {
grep -qE -- "$1(]|[[:space:]][[:upper:]]|[[:space:]]{2}|$)" <<<"$2"
}
# Probed for the flags omarchy-agent actually passes -- --query to seed the
# session and --tui to keep it interactive -- rather than a marker standing in
# for them.
hermes_prompt_ready() {
local help
hermes_runs &&
help=$(timeout 15 "$command_path" chat --help 2>/dev/null) &&
help_defines_flag '--tui' "$help" &&
help_defines_flag '--query' "$help"
}
# The command upstream's installer writes execs into the runtime. Anything else
# at that path -- an official install elsewhere, a hand-rolled wrapper, even a
# dangling link -- is the user's. Matched as a plain string with its trailing
# slash, because the path carries a dot and a bare prefix would also claim a
# wrapper pointing at ~/.hermes-old.
ours() {
[[ -f $command_path && ! -L $command_path ]] && grep -qF "$HERMES_HOME/" "$command_path"
}
# What omarchy-agent runs is whichever hermes is first on PATH, and Omarchy puts
# mise's shims ahead of ~/.local/bin. So the command the probe vets has to be
# the one PATH finds, or the agent is on a different Hermes than the one set
# up. A PATH that finds none is not in the way: the theme unit asks from a
# service whose PATH has no ~/.local/bin, and runs the command by its path.
on_path() {
local found
found=$(type -P hermes) || return 0
[[ $(realpath -m -- "$found") == "$(realpath -m -- "$command_path")" ]]
}
shadowing_hermes() {
type -P hermes || true
}
# The installer writes the marker last, so it is the one thing that says a
# runtime finished installing rather than merely started; the venv appears
# several stages earlier. And a marker left behind by an install whose venv has
# since gone answers for nothing, so the command has to run.
runtime_ready() {
[[ -f $runtime/.hermes-bootstrap-complete && -f $runtime/venv/bin/hermes && -x $runtime/venv/bin/hermes && -f $runtime/venv/bin/python && -x $runtime/venv/bin/python ]] &&
timeout 15 "$runtime/venv/bin/hermes" --version >/dev/null 2>&1
}
native_app_complete() {
[[ -f $native_app/Hermes && -x $native_app/Hermes && -f $native_app/resources/app.asar && -f $native_app/resources/install-stamp.json ]]
}
# What --check answers, and what --now has nothing left to do about: the app
# is installed and the Hermes on the machine is its own -- the command
# upstream's installer wrote, the runtime's completion marker and the seeded
# packaged app, because the app only runs against a runtime it prepared and
# the terminal has to be on the same one -- and that command is the one PATH
# finds, with nothing of the retired wrapper's left to remove. The two agree
# on purpose: choosing Hermes from the menu asks first and opens a terminal
# only on a no, so a yes here has to mean --now would take no minutes-long
# step where nobody can watch it.
installed() {
on_path || return 1
if legacy_owned; then
return 1
fi
omarchy-pkg-present hermes-desktop && ours && [[ -f $runtime/.hermes-bootstrap-complete ]] && native_app_complete && hermes_prompt_ready
}
# Once a Hermes answers at the command's path, the environment the retired
# wrapper built goes with whatever proof is left of it, and then PATH has to
# agree with the probe: a mise shim, or anything else ahead of ~/.local/bin, is
# what the default agent would run instead.
settle_path() {
if legacy_owned; then
echo "Removing the Hermes that mise built; Hermes now updates itself..."
retire_mise || return 1
forget_legacy
fi
if ! on_path; then
echo "$command_path is ready, but 'hermes' on PATH is $(shadowing_hermes), which is what the default agent runs." >&2
echo "Remove or reorder it, then run omarchy-install-hermes-cli --now again." >&2
return 1
fi
}
# The updater switches to main before checking for changes, so main has to
# start at the packaged release. Whatever main pointed at first is kept under
# another name rather than judged: upstream rewrites its history often enough
# that a clone's main sits off origin/main with no local commit on it, and a
# user's own commits are exactly what must not be lost either way.
keep_main() {
local main_commit kept
main_commit=$(git -C "$runtime" rev-parse --verify refs/heads/main 2>/dev/null || true)
if [[ -n $main_commit && $main_commit != "$release_commit" && $main_commit != "$(git -C "$runtime" rev-parse --verify refs/remotes/origin/main 2>/dev/null)" ]]; then
# A run that stopped after this point keeps it again on the next try; one
# branch at that commit is enough.
if [[ -z $(git -C "$runtime" for-each-ref --points-at "$main_commit" 'refs/heads/main-before-omarchy-*') ]]; then
kept="main-before-omarchy-$(date +%s)"
git -C "$runtime" branch "$kept" "$main_commit"
echo "Kept what Hermes main pointed at as the branch $kept; main now starts at the packaged release."
fi
fi
}
# The repository a process's GIT_DIR names, canonical: relative to its working
# directory when relative, and with no trailing slash or dot segments either
# way. Read NUL-delimited as the kernel writes it, never through a pipe: a
# path may carry a newline, and grep stopping at a match would fail a pipe
# under pipefail and call a busy git idle.
git_dir_of() {
local pid=$1 entry dir="" cwd
while IFS= read -r -d '' entry; do
if [[ $entry == GIT_DIR=* ]]; then
dir=${entry#GIT_DIR=}
break
fi
done <"/proc/$pid/environ" 2>/dev/null || return 1
[[ -n $dir ]] || return 1
if [[ $dir != /* ]]; then
cwd=$(readlink "/proc/$pid/cwd" 2>/dev/null) || return 1
dir="$cwd/$dir"
fi
realpath -m -- "$dir"
}
# A git of this user working in the runtime: a fetch Hermes started to work
# out its --version, or its updater. Found by working directory, or by a
# GIT_DIR in its environment, rather than by command line, since the remote
# helpers git spawns carry no repository path on theirs. Compared canonical,
# because /proc resolves every link and the runtime path keeps them.
git_busy_in_runtime() {
local pid cwd dir real
real=$(realpath -e -- "$runtime" 2>/dev/null) || return 1
for pid in $(pgrep -u "$(id -u)" -f '^([^ ]*/)?git(-remote-[a-z]+)?( |$)' 2>/dev/null); do
cwd=$(readlink "/proc/$pid/cwd" 2>/dev/null) || continue
if [[ $cwd == "$real" || $cwd == "$real/"* ]]; then
return 0
fi
if dir=$(git_dir_of "$pid") && [[ $dir == "$real/.git" ]]; then
return 0
fi
done
return 1
}
# Hermes works out its --version against origin, and the probe's 15 second
# budget can kill it mid-fetch, which leaves git's shallow.lock behind; a fetch
# of ours that a network blip interrupted leaves the same. Git refuses to clear
# a lock it did not take, and a file's age alone cannot say whether anything
# still holds it, so a live git in the runtime is waited for first, and only a
# lock nobody holds and nothing has touched for a minute is cleared. The fetch
# itself gets three tries.
unshallow_runtime() {
local attempt waited lock="$runtime/.git/shallow.lock"
for attempt in 1 2 3; do
waited=0
while git_busy_in_runtime && (( waited < 60 )); do
if (( waited == 0 )); then
echo "Waiting for Hermes to finish working in $runtime..."
fi
sleep 2
waited=$(( waited + 2 ))
done
if git_busy_in_runtime; then
echo "Hermes is still working in $runtime. Let it finish, then run omarchy-install-hermes-cli --now again." >&2
return 1
fi
if [[ -e $lock && -n $(find "$lock" -mmin +1 2>/dev/null) ]]; then
rm -f "$lock"
fi
if git -C "$runtime" fetch --unshallow origin main; then
return 0
fi
if (( attempt < 3 )); then
sleep 2
fi
done
echo "Could not fetch the Hermes history from origin. Check the network, then run omarchy-install-hermes-cli --now again." >&2
return 1
}
case "$mode" in
--check)
if installed; then exit 0; else exit 1; fi
;;
--retire-mise)
# The migration's whole job. The environment goes before the wrapper, since
# once the wrapper is gone nothing marks the environment as Omarchy's and a
# rerun could not finish what a failed removal left behind.
if legacy_owned; then
retire_mise || exit 1
forget_legacy
fi
exit 0
;;
--now) ;;
*)
echo "Usage: omarchy-install-hermes-cli <--check|--now|--retire-mise>" >&2
exit 1
;;
esac
if (( EUID == 0 )); then
echo "Run this command as your desktop user, without sudo." >&2
exit 1
fi
if installed; then
exit 0
fi
if ! omarchy-pkg-present hermes-desktop; then
echo "Installing Hermes Desktop..."
omarchy-pkg-add hermes-desktop
fi
if [[ ! -r /usr/share/hermes-desktop/install.sh || ! -r /usr/share/hermes-desktop/runtime.patch ]] ||
! release_commit=$(jq -er 'select(.branch == "main") | .commit | select(test("^[0-9a-f]{40}$"))' /opt/hermes-desktop/resources/install-stamp.json 2>/dev/null); then
echo "The installed Hermes package cannot prepare in-app updates. Run 'omarchy update', then try again." >&2
exit 1
fi
runtime_present=false
if runtime_ready; then
runtime_present=true
fi
# A seeded app has to be whole, whatever state the runtime beside it is in.
refuse_unless_app_whole() {
if [[ -e $native_app || -L $native_app ]] && ! native_app_complete; then
echo "The Hermes desktop app at $native_app is incomplete. Repair it with 'hermes desktop --build-only' before trying again." >&2
return 1
fi
}
# The packaged app can be seeded only beside a runtime at the release it was
# built from, with its desktop sources untouched and the Linux runtime patch
# still able to land. Asked before a command is replaced, so that a refusal
# leaves the user's Hermes exactly as it was, and again before seeding.
# Asked through ||, so nothing in here stops on its own: every git answer is
# checked, and one that cannot be read refuses rather than reading as clean.
refuse_unless_seedable() {
local runtime_commit changes
refuse_unless_app_whole || return 1
if [[ -e $native_app || -L $native_app ]]; then
return 0
fi
if ! runtime_commit=$(git -C "$runtime" rev-parse HEAD 2>/dev/null) || [[ -z $runtime_commit ]]; then
echo "Could not read the Hermes runtime's git state at $runtime. Repair that installation before trying again; existing files have been kept." >&2
return 1
fi
if [[ $runtime_commit != "$release_commit" ]]; then
echo "The Hermes runtime has moved beyond the packaged desktop release. Run 'hermes desktop --build-only', then try again." >&2
return 1
fi
if ! changes=$(git -C "$runtime" status --porcelain --untracked-files=all -- apps/desktop package.json package-lock.json 2>/dev/null); then
echo "Could not read the Hermes runtime's git state at $runtime. Repair that installation before trying again; existing files have been kept." >&2
return 1
fi
if [[ -n $changes ]]; then
echo "Hermes desktop sources have local changes. Run 'hermes desktop --build-only', then try again; existing files have been kept." >&2
return 1
fi
if ! git -C "$runtime" apply --check /usr/share/hermes-desktop/runtime.patch >/dev/null 2>&1 &&
! git -C "$runtime" apply --reverse --check /usr/share/hermes-desktop/runtime.patch >/dev/null 2>&1; then
echo "The Hermes Linux runtime patch conflicts with local changes. Existing files have been kept." >&2
return 1
fi
}
if [[ $runtime_present == "true" ]]; then
refuse_unless_seedable || exit 1
elif [[ -e $runtime || -L $runtime ]]; then
# The upstream installer can reset an existing checkout. Do not pin a newer
# or modified runtime back to the package release while repairing setup,
# and a git state that cannot be read is not a clean one.
refuse_unless_app_whole || exit 1
if ! runtime_commit=$(git -C "$runtime" rev-parse HEAD 2>/dev/null) || [[ $runtime_commit != "$release_commit" ]] ||
! changes=$(git -C "$runtime" status --porcelain --untracked-files=all 2>/dev/null) || [[ -n $changes ]]; then
echo "Hermes setup is incomplete at $runtime. Repair that installation before trying again; existing files have been kept." >&2
exit 1
fi
fi
# Whether this run gave Hermes something new to show the theme to: a runtime
# it set up, or the app it seeded. Putting a command back is neither.
set_up=false
# The command is the runtime's own and runs; anything else at the path gets
# replaced below. The retired wrapper is never run to find out.
command_ready=false
if ! legacy_stub && ours && hermes_runs; then
command_ready=true
fi
# Upstream replaces these commands, including foreign files and symlinks,
# whether it sets the runtime up or only writes the commands again. Keep their
# original bytes/links before handing the names to the runtime.
if [[ $runtime_present == "false" || $command_ready == "false" ]]; then
command_backup=""
for command in hermes hermes-agent hermes-acp; do
existing="$HOME/.local/bin/$command"
if [[ -e $existing || -L $existing ]]; then
if [[ ! -f $existing && ! -L $existing ]]; then
echo "Cannot replace $existing: move it aside before installing Hermes." >&2
exit 1
fi
if [[ -z $command_backup ]]; then
command_backup=$(mktemp -d "$HOME/.local/bin/.hermes-before-desktop.XXXXXX")
echo "Saving existing Hermes commands in $command_backup"
fi
cp -a -- "$existing" "$command_backup/"
fi
done
fi
if [[ $runtime_present == "false" ]]; then
echo "Setting up the Hermes runtime..."
# npx stops to ask before fetching Playwright for the browser tools, and in
# the terminal the menu opens for this nobody is there to say yes.
npm_config_yes=true bash /usr/share/hermes-desktop/install.sh --skip-setup --branch main --commit "$release_commit" --force-commit --dir "$runtime" --hermes-home "$HERMES_HOME"
if ! runtime_ready; then
echo "Hermes runtime setup did not complete. Re-run this command after resolving the installer error." >&2
exit 1
fi
set_up=true
elif [[ $command_ready == "false" ]]; then
# The runtime is in place but the command is not its own, or does not run:
# gone, the retired wrapper, one the user put there, or the runtime's own
# with its mode bits stripped. Upstream's path stage writes its commands
# without touching the checkout.
echo "Restoring the Hermes commands..."
bash /usr/share/hermes-desktop/install.sh --stage path --dir "$runtime" --hermes-home "$HERMES_HOME"
fi
runtime_commit=$(git -C "$runtime" rev-parse HEAD)
if [[ $runtime_commit == "$release_commit" ]]; then
# Start main at the packaged release, with enough history for its first
# fast-forward.
keep_main
if [[ $(git -C "$runtime" rev-parse --is-shallow-repository) == "true" ]]; then
unshallow_runtime
fi
git -C "$runtime" switch -C main "$release_commit"
if git -C "$runtime" apply --check /usr/share/hermes-desktop/runtime.patch >/dev/null 2>&1; then
git -C "$runtime" apply /usr/share/hermes-desktop/runtime.patch
elif ! git -C "$runtime" apply --reverse --check /usr/share/hermes-desktop/runtime.patch >/dev/null 2>&1; then
echo "The Hermes Linux runtime patch conflicts with local changes. Existing files have been kept." >&2
exit 1
fi
fi
refuse_unless_seedable || exit 1
if [[ ! -e $native_app && ! -L $native_app ]]; then
mkdir -p -- "${native_app%/*}"
staging=$(mktemp -d "${native_app%/*}/.linux-unpacked.XXXXXX")
trap 'rm -rf -- "$staging"' EXIT
cp -a /opt/hermes-desktop/. "$staging/"
chmod 0755 "$staging/chrome-sandbox"
mv -T --no-clobber -- "$staging" "$native_app"
if [[ -e $staging ]]; then
echo "A Hermes desktop app appeared during setup. It has been kept; please try again." >&2
exit 1
fi
trap - EXIT
# Record this matching prebuilt app using the CLI's own content hash, so
# subsequent menu launches do not rebuild an app that is already current.
env -u PYTHONPATH -u PYTHONHOME "$runtime/venv/bin/python" - "$runtime" <<'PY'
import sys
from pathlib import Path
sys.path.insert(0, sys.argv[1])
if Path(sys.argv[1], "hermes_cli/main_desktop.py").is_file():
from hermes_cli.main_desktop import _write_desktop_build_stamp
else:
from hermes_cli.main import _write_desktop_build_stamp
_write_desktop_build_stamp(Path(sys.argv[1]), source_mode=False)
PY
set_up=true
fi
# What omarchy-agent runs is the command, not the venv, so the command is what
# has to answer for the seeded sessions.
if ! hermes_prompt_ready; then
echo "Hermes is installed at $runtime, but $command_path does not run the interactive seeded sessions Omarchy needs." >&2
echo "Update Hermes with 'hermes update', then run omarchy-install-hermes-cli --now again." >&2
exit 1
fi
settle_path || exit 1
# Only a running Hermes can be told which skin to show; a unit outlives this
# terminal and reports to the journal.
if [[ $set_up == "true" ]]; then
echo "Matching Hermes to the current theme once it is set up..."
systemctl --user stop omarchy-hermes-theme.service 2>/dev/null || true
systemd-run --user --quiet --collect --unit=omarchy-hermes-theme omarchy-theme-set-hermes --wait
fi
-271
View File
@@ -1,271 +0,0 @@
#!/bin/bash
# omarchy:summary=Install OpenClaw for the default agent as the self-updating copy under ~/.openclaw
# omarchy:args=<--check|--now>
# omarchy:examples=omarchy install openclaw cli --now | omarchy install openclaw cli --check
# omarchy:requires-sudo=true
# There is one OpenClaw on a machine, and it is the one under ~/.openclaw that
# upstream's install-cli.sh makes: a private Node and the package in a prefix
# the user owns, which `openclaw update` and the Control UI's Update button
# replace in place. A copy in /usr belongs to pacman, and upstream's updater
# refuses to touch it. The openclaw package is therefore the seed rather than
# the runtime: the release Omarchy ships and the installer that came with it.
# The CLI, the gateway service and the Install > AI web app all run this copy.
#
# Each mode is named outright, like omarchy-install-hermes-cli: the default
# agent asks --check first and opens a terminal for --now only on a no.
set -euo pipefail
mode=${1:-}
# Everything here runs the user's own files, --check included.
if (( EUID == 0 )); then
echo "Run this command as your desktop user, without sudo." >&2
exit 1
fi
# Omarchy's OpenClaw is the default one, in ~/.openclaw with the default unit,
# whatever another profile, unit or state directory a shell selects; a service
# installed from here would otherwise carry that selection.
unset OPENCLAW_PROFILE OPENCLAW_SYSTEMD_UNIT OPENCLAW_WRAPPER OPENCLAW_HOME OPENCLAW_STATE_DIR OPENCLAW_CONFIG_PATH
prefix="$HOME/.openclaw"
runtime_command="$prefix/bin/openclaw"
command_path="$HOME/.local/bin/openclaw"
seed=/usr/share/openclaw
# Usable means the command answers, not that the file is there: upstream's
# wrapper execs the prefix's own Node, so a prefix missing either fails here.
runtime_runs() {
[[ -f $runtime_command && -x $runtime_command ]] &&
timeout 30 "$runtime_command" --version >/dev/null 2>&1
}
# The name on PATH is a link to the runtime's command. A link already aimed
# there, even one left dangling by a removed runtime, is Omarchy's to rewrite;
# anything else at the path is the user's.
command_ours() {
[[ ! -e $command_path && ! -L $command_path ]] || [[ $(readlink -- "$command_path") == "$runtime_command" ]]
}
# What omarchy-agent, the web app and a terminal run is whichever openclaw is
# first on PATH, and Omarchy puts /usr/bin and the mise shims ahead of
# ~/.local/bin, so the command has to resolve to the runtime from there.
on_path() {
local found
found=$(type -P openclaw) || return 1
[[ $(realpath -m -- "$found") == "$(realpath -m -- "$runtime_command")" ]]
}
# Nothing yet, or the runtime's command.
path_clear() {
! type -P openclaw >/dev/null || on_path
}
# The package counts too: without it --now has a pacman step to take, and
# answering yes here would run that where no terminal can ask for a password.
installed() {
omarchy-pkg-present openclaw && runtime_runs && on_path
}
# A unit's ExecStart, as the program and the script it is handed: the first
# argument that is not an option. Later arguments and other lines can name
# ~/.openclaw whichever OpenClaw the unit starts.
unit_program() {
local words
read -ra words <<<"$(sed -n 's/^ExecStart=//p' "$1" | head -1)"
echo "${words[0]:-}"
}
unit_script() {
local words word
read -ra words <<<"$(sed -n 's/^ExecStart=//p' "$1" | head -1)"
for word in "${words[@]:1}"; do
if [[ $word != -* ]]; then
echo "$word"
return
fi
done
}
# The runtime's own OpenClaw, as upstream's installer lays it out.
runs_runtime() {
[[ $(unit_script "$1") == "$prefix"/tools/*/lib/node_modules/openclaw/* ]]
}
# A gateway service already running some other OpenClaw. Upstream's installer
# rewrites a loaded gateway service to the copy it has just installed, so
# seeding beside one would take it over.
foreign_gateway() {
local unit
unit=$(unit_path gateway)
[[ -f $unit ]] && ! runs_runtime "$unit" && ! on_old_package "$unit"
}
# A service the openclaw package's own copy installed runs from
# /usr/lib/node_modules/openclaw, which that package no longer ships, and is
# moved to the runtime, where `openclaw update` can replace its code. A service
# running any other OpenClaw is the user's arrangement and stays. The --now
# steps below list these, and which were running, before anything changes.
unit_path() {
echo "$HOME/.config/systemd/user/openclaw-$1.service"
}
# Stopped before anything is installed: still running the old code from deleted
# files, such a service holds the state directory the newer runtime has to
# migrate, which upstream's installer does the moment it finds one loaded.
stop_legacy_services() {
local role
for role in "${moving[@]}"; do
if ! systemctl --user stop "openclaw-$role.service"; then
echo "Could not stop the OpenClaw $role service, so OpenClaw was not moved to $prefix." >&2
return 1
fi
if [[ " ${running[*]} " == *" $role "* ]]; then
stopped+=("$role")
fi
done
}
# Still the old package's: its script, or the runtime's script on the old
# package's Node. Upstream's installer keeps the Node a service already ran, so
# a moved unit can end up running the runtime's OpenClaw on /usr/bin/node, from
# the nodejs package the seed no longer depends on. Any other Node, Bun or
# script is the user's.
on_old_package() {
[[ $(unit_script "$1") == /usr/lib/node_modules/openclaw/* ]] ||
{ [[ $(unit_program "$1") == "/usr/bin/node" ]] && runs_runtime "$1"; }
}
# Not moved yet: still on the old package, or running before and not now.
unsettled() {
on_old_package "$(unit_path "$1")" ||
{ [[ " ${running[*]} " == *" $1 "* ]] && ! systemctl --user is-active --quiet "openclaw-$1.service"; }
}
# Upstream's installer rewrites a loaded gateway itself, but only warns when
# the restart fails, so whatever is still unsettled is installed again from the
# runtime, on the runtime's own Node, and then has to have settled.
rehome_services() {
local role node pin=()
if node=$(realpath -e -- "$prefix/tools/node/bin/node" 2>/dev/null); then
pin=(--runtime-path "$node")
fi
for role in "${moving[@]}"; do
if unsettled "$role"; then
echo "Moving the OpenClaw $role service to $prefix..."
"$runtime_command" "$role" install --force "${pin[@]}" || true
fi
if unsettled "$role"; then
echo "Could not move the OpenClaw $role service to $prefix. Finish with: openclaw $role install --force" >&2
return 1
fi
# Installing a service enables and starts it; one the user had left
# stopped or disabled goes back to that.
if [[ " ${running[*]} " != *" $role "* ]]; then
systemctl --user stop "openclaw-$role.service" || true
fi
if [[ " ${enabled[*]} " != *" $role "* ]]; then
systemctl --user disable "openclaw-$role.service" 2>/dev/null || true
fi
done
}
# Said on any failure that leaves a service this run stopped not running.
stopped_note() {
local role
for role in "${stopped[@]}"; do
if ! systemctl --user is-active --quiet "openclaw-$role.service"; then
echo "The OpenClaw $role service was stopped for this and is not running now." >&2
fi
done
}
case "$mode" in
--check)
if installed; then exit 0; else exit 1; fi
;;
--now) ;;
*)
echo "Usage: omarchy-install-openclaw-cli <--check|--now>" >&2
exit 1
;;
esac
# Refused before anything is installed or touched: the command's name, what
# PATH finds, and a gateway running another OpenClaw all have to be clear.
if ! command_ours; then
echo "$command_path is not the OpenClaw Omarchy set up. Move it aside, then run omarchy-install-openclaw-cli --now again." >&2
exit 1
fi
if ! path_clear; then
echo "'openclaw' on PATH is $(type -P openclaw), which is what Omarchy would run instead of $runtime_command." >&2
echo "Remove it or reorder PATH, then run omarchy-install-openclaw-cli --now again." >&2
exit 1
fi
# A runtime that already answers is never reinstalled: it may be past the
# packaged release by its own updates, and seeding would take it back.
seeding=false
if ! runtime_runs; then
seeding=true
fi
if [[ $seeding == "true" ]] && foreign_gateway; then
echo "The OpenClaw gateway service runs another OpenClaw, which setting one up in $prefix would take over." >&2
echo "Remove that gateway with 'openclaw gateway uninstall', then run omarchy-install-openclaw-cli --now again." >&2
exit 1
fi
if ! omarchy-pkg-present openclaw; then
echo "Installing OpenClaw..."
omarchy-pkg-add openclaw
fi
if [[ $seeding == "true" && ( ! -r $seed/install-cli.sh || ! -r $seed/openclaw.tgz ) ]]; then
echo "The installed OpenClaw package cannot set up a self-updating OpenClaw. Run 'omarchy update', then try again." >&2
exit 1
fi
# What the old package installed, and what of it was running and enabled, read
# before anything changes.
moving=()
running=()
enabled=()
stopped=()
for role in gateway node; do
if [[ -f $(unit_path "$role") ]] && on_old_package "$(unit_path "$role")"; then
moving+=("$role")
if systemctl --user is-active --quiet "openclaw-$role.service"; then
running+=("$role")
fi
if systemctl --user is-enabled --quiet "openclaw-$role.service"; then
enabled+=("$role")
fi
fi
done
trap 'status=$?; (( status == 0 )) || stopped_note' EXIT
stop_legacy_services
if [[ $seeding == "true" ]]; then
# Every choice the installer reads from the environment is named, so an
# OPENCLAW_INSTALL_METHOD or OPENCLAW_PREFIX left in a shell cannot move it.
echo "Setting up OpenClaw in $prefix..."
bash "$seed/install-cli.sh" --install-method npm --prefix "$prefix" --version "$seed/openclaw.tgz" --no-onboard || true
if ! runtime_runs; then
echo "OpenClaw setup did not complete. Re-run this command after resolving the installer error." >&2
exit 1
fi
fi
mkdir -p "${command_path%/*}"
ln -sfn "$runtime_command" "$command_path"
rehome_services
if ! on_path; then
echo "$runtime_command is ready, but 'openclaw' on PATH is $(type -P openclaw || echo missing), which is what Omarchy runs." >&2
echo "Put ~/.local/bin on PATH, then run omarchy-install-openclaw-cli --now again." >&2
exit 1
fi
+2 -3
View File
@@ -21,11 +21,10 @@ if gum confirm "Are you sure you want to restore all preinstalled web apps, TUI
obs-studio \ obs-studio \
kdenlive \ kdenlive \
moonlight-qt \ moonlight-qt \
lazydocker \
omacut \ omacut \
monologue \
omacalc \ omacalc \
omawrite \ omawrite; then
hype; then
echo -e "\nPreinstalls are still marked as removed. Fix the errors above and try again." echo -e "\nPreinstalls are still marked as removed. Fix the errors above and try again."
exit 1 exit 1
fi fi
+1 -1
View File
@@ -28,7 +28,7 @@ echo "Installing 1Password extension for Chromium..."
install_chromium_extension install_chromium_extension
echo "Opening 1Password..." echo "Opening 1Password..."
setsid uwsm-app -- 1password >/dev/null 2>&1 & uwsm-app -- 1password >/dev/null 2>&1 &
echo "" echo ""
echo "1Password has been installed. Restart Chromium to load the browser extension." echo "1Password has been installed. Restart Chromium to load the browser extension."
-1
View File
@@ -1,7 +1,6 @@
#!/bin/bash #!/bin/bash
# omarchy:summary=Install and start the Dropbox service. Must then be authenticated via the web. # omarchy:summary=Install and start the Dropbox service. Must then be authenticated via the web.
# omarchy:requires-sudo=true
echo "Installing all dependencies..." echo "Installing all dependencies..."
omarchy-pkg-add dropbox dropbox-cli libappindicator-gtk3 python-gpgme nautilus-dropbox omarchy-pkg-add dropbox dropbox-cli libappindicator-gtk3 python-gpgme nautilus-dropbox
-28
View File
@@ -1,28 +0,0 @@
#!/bin/bash
# omarchy:summary=Install Microsoft Outlook, Word, Excel, PowerPoint, Teams, and OneDrive as web apps.
set -e
ICONS="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png"
echo "Installing Microsoft Outlook..."
omarchy-webapp-install "Microsoft Outlook" "https://outlook.office.com/mail/" "$ICONS/microsoft-outlook.png"
echo "Installing Microsoft Word..."
omarchy-webapp-install "Microsoft Word" "https://word.cloud.microsoft/" "$ICONS/microsoft-word.png"
echo "Installing Microsoft Excel..."
omarchy-webapp-install "Microsoft Excel" "https://excel.cloud.microsoft/" "$ICONS/microsoft-excel.png"
echo "Installing Microsoft PowerPoint..."
omarchy-webapp-install "Microsoft PowerPoint" "https://powerpoint.cloud.microsoft/" "$ICONS/microsoft-powerpoint.png"
echo "Installing Microsoft Teams..."
omarchy-webapp-install "Microsoft Teams" "https://teams.cloud.microsoft/" "$ICONS/microsoft-teams.png"
echo "Installing Microsoft OneDrive..."
omarchy-webapp-install "Microsoft OneDrive" "https://www.office.com/launch/onedrive" "$ICONS/microsoft-onedrive.png"
echo ""
echo "You can now find Microsoft Outlook, Word, Excel, PowerPoint, Teams, and OneDrive using the app launcher (SUPER + SPACE)"
+1 -4
View File
@@ -4,11 +4,8 @@
set -e set -e
# 1Password reads the display scale itself, the way Electron apps do, and comes
# up oversized next to every other window on a scaled monitor. The packaged
# .desktop pins it too; this covers the hotkey, which runs the binary directly.
if omarchy-cmd-present 1password; then if omarchy-cmd-present 1password; then
exec setsid uwsm-app -- 1password --force-device-scale-factor=1 exec setsid uwsm-app -- 1password
else else
exec omarchy-launch-floating-terminal-with-presentation omarchy-install-service-1password exec omarchy-launch-floating-terminal-with-presentation omarchy-install-service-1password
fi fi
+7 -6
View File
@@ -3,7 +3,10 @@
# omarchy:summary=Launch the default browser as determined by xdg-settings. # omarchy:summary=Launch the default browser as determined by xdg-settings.
# omarchy:args=[url] # omarchy:args=[url]
default_browser=$(omarchy-cmd-default-browser) default_browser=$(env -u BROWSER xdg-settings get default-web-browser)
if [[ -z $default_browser ]]; then
default_browser=$(xdg-mime query default x-scheme-handler/https)
fi
browser_exec=$(sed -n 's/^Exec=\([^ ]*\).*/\1/p' {~/.local,~/.nix-profile,/usr}/share/applications/$default_browser 2>/dev/null | head -1) browser_exec=$(sed -n 's/^Exec=\([^ ]*\).*/\1/p' {~/.local,~/.nix-profile,/usr}/share/applications/$default_browser 2>/dev/null | head -1)
if $browser_exec --help 2>/dev/null | grep -q MOZ_LOG; then if $browser_exec --help 2>/dev/null | grep -q MOZ_LOG; then
@@ -14,11 +17,9 @@ else
private_flag="--incognito" private_flag="--incognito"
fi fi
if ! omarchy-cmd-browser-handoff "$browser_exec" "${@/--private/$private_flag}"; then systemd-run --user --quiet --collect --unit="omarchy-browser-$(date +%s%N)" \
systemd-run --user --quiet --collect --unit="omarchy-browser-$(date +%s%N)" \ --property=StandardOutput=null --property=StandardError=null \
--property=StandardOutput=null --property=StandardError=null \ uwsm-app -- "$browser_exec" "${@/--private/$private_flag}"
uwsm-app -- "$browser_exec" "${@/--private/$private_flag}"
fi
url="" url=""
for argument in "$@"; do for argument in "$@"; do
+3 -6
View File
@@ -18,20 +18,17 @@ else
editor="nvim" editor="nvim"
fi fi
# sublime-text-4 ships `subl` on PATH, not `sublime_text`.
[[ $editor == sublime_text ]] && editor=subl
omarchy-cmd-present "$editor" || editor="nvim" omarchy-cmd-present "$editor" || editor="nvim"
case "${editor##*/}" in case "${editor##*/}" in
nvim | vim | nano | micro | hx | helix | fresh) nvim | vim | nano | micro | hx | helix | fresh)
if [[ $inline == "true" ]]; then if [[ $inline == "true" ]]; then
exec "$editor" -- "$@" exec "$editor" "$@"
else else
exec omarchy-launch-tui "$editor" -- "$@" exec omarchy-launch-tui "$editor" "$@"
fi fi
;; ;;
*) *)
exec setsid uwsm-app -- "$editor" -- "$@" exec setsid uwsm-app -- "$editor" "$@"
;; ;;
esac esac
@@ -8,7 +8,6 @@
source omarchy-restart-gum source omarchy-restart-gum
cmd="$*" cmd="$*"
# 130 is the user bailing with Ctrl-C; anything else gets the Done/Failed prompt. presentation_script="omarchy-show-logo; $cmd; if (( \$? != 130 )); then omarchy-show-done; fi"
presentation_script="omarchy-show-logo; $cmd; code=\$?; if (( code != 130 )); then omarchy-show-done \$code; fi"
exec setsid uwsm-app -- xdg-terminal-exec --app-id=org.omarchy.terminal --title=Omarchy -e bash -c "$presentation_script" exec setsid uwsm-app -- xdg-terminal-exec --app-id=org.omarchy.terminal --title=Omarchy -e bash -c "$presentation_script"
-91
View File
@@ -1,91 +0,0 @@
#!/bin/bash
# omarchy:summary=Open the OpenClaw Control UI (or its terminal UI with --tui), onboarding or starting the gateway first when needed.
# omarchy:args=[--tui [--message <text>]]
set -euo pipefail
tui=false
message=()
if [[ ${1:-} == "--tui" ]]; then
tui=true
shift
if [[ ${1:-} == "--message" ]]; then
message=(--message "${2:?--message needs a value}")
shift 2
fi
fi
# Onboarding is OpenClaw's own interactive wizard, run through
# omarchy-openclaw-onboard so it stays in the terminal, installs the gateway
# as a user service, and returns (see that script for why bare `openclaw
# onboard` does none of those as of 2026.9.1). The config check gates what
# follows because the wizard's "Skip for now" also exits 0: only inference
# that passed writes the config, and skipping must not loop back into the
# wizard.
if [[ ! -f $HOME/.openclaw/openclaw.json ]]; then
if [[ $tui == "true" ]]; then
# Already in a terminal, so the wizard runs right here.
omarchy-openclaw-onboard
[[ -f $HOME/.openclaw/openclaw.json ]] || exit 1
else
# The wizard needs a real terminal, and chaining the relaunch means
# finishing it lands the user in the app. Single quotes so $HOME expands
# in the spawned terminal.
# shellcheck disable=SC2016
exec omarchy-launch-floating-terminal-with-presentation \
'omarchy-openclaw-onboard && [[ -f $HOME/.openclaw/openclaw.json ]] && omarchy-launch-openclaw'
fi
fi
dashboard_url() {
# browserUrl carries a single-use browser handoff; url is the shared-auth
# fallback for gateways predating the handoff flow. The timeout keeps a
# wedged CLI from hanging an app-grid launch that has no terminal to ^C.
timeout 10 openclaw dashboard --json 2>/dev/null | jq -re '.browserUrl // .url // empty'
}
# --json never starts the gateway, so an empty answer means it is not running.
# Recovery goes through the gateway's own service commands. `dashboard --yes`
# used to be the start/install-without-prompting path, but as of 2026.9.1 it
# defers to "the owning supervisor" for both a missing and a stopped unit,
# and once the gateway is up it copies a one-time pairing URL into the
# clipboard, which nothing here needs. Enablement, not the unit file, decides:
# a unit that was written but never enabled (an install that died halfway)
# would otherwise be "started" once and stay off at every following login,
# where --force rewrites and enables it.
if ! url=$(dashboard_url); then
if systemctl --user is-enabled --quiet openclaw-gateway.service 2>/dev/null; then
timeout 60 openclaw gateway start >&2 || true
else
timeout 120 openclaw gateway install --force >&2 || true
fi
# A first-ever service install (unit write, daemon-reload, first boot)
# takes materially longer than starting an installed unit, so the budget
# is sized for the slow case.
for _ in {1..30}; do
url=$(dashboard_url) && break
sleep 1
done
fi
if [[ -z ${url:-} ]]; then
echo "OpenClaw's gateway did not come up. Check it with: openclaw gateway status" >&2
echo "If onboarding never finished, rerun it with: omarchy-openclaw-onboard" >&2
exit 1
fi
if [[ $tui == "true" ]]; then
# Attach to the gateway rather than `openclaw chat`: chat is the embedded
# local runtime, which refuses to start while the gateway owns ~/.openclaw's
# state directory -- and on any machine set up through Install > AI, the
# gateway service is running whenever the desktop session is.
exec openclaw tui "${message[@]}"
fi
# The handoff URL rides in the browser's argv, which uwsm's app daemon echoes
# into the user journal. Accepted: the token is single-use with a ten-minute
# expiry against a loopback-only gateway, and journal access already implies
# access to ~/.openclaw itself.
exec omarchy-launch-webapp "$url"
+3 -16
View File
@@ -29,8 +29,7 @@ hypr_exec() {
local command local command
printf -v command '%q ' "$@" printf -v command '%q ' "$@"
hyprctl dispatch "hl.dsp.exec_cmd([[[workspace $workspace] $command]])" >/dev/null 2>&1 || hyprctl dispatch "hl.dsp.exec_cmd([[$command]])" >/dev/null 2>&1 || hyprctl dispatch exec -- bash -lc "$command" >/dev/null
hyprctl dispatch exec -- "[workspace $workspace]" bash -lc "$command" >/dev/null
} }
SOCKET="$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock" SOCKET="$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock"
@@ -50,10 +49,8 @@ wait_for_screensaver_window() {
done done
} }
while read -r m special; do for m in $(hyprctl monitors -j | jq -r '.[] | .name'); do
hypr_focus_monitor "$m" hypr_focus_monitor "$m"
# Share a special workspace that is already showing, like the scratchpad, so it is still open afterwards.
workspace=${special:-special:screensaver-$m}
case $terminal in case $terminal in
*Alacritty*) *Alacritty*)
@@ -71,16 +68,6 @@ while read -r m special; do
esac esac
wait_for_screensaver_window wait_for_screensaver_window
done < <(hyprctl monitors -j | jq -r '.[] | "\(.name) \(.specialWorkspace.name)"') done
hypr_focus_monitor "$focused" hypr_focus_monitor "$focused"
# Emptying a special workspace focuses its monitor, so the last screensaver to close would take focus
# with it. Hand focus back to the monitor that had it once they are all gone.
# The class goes through the environment, since in argv the screensaver's pgrep and pkill would match it.
(
while hyprctl clients -j | SCREENSAVER=org.omarchy.screensaver jq -e 'any(.[]; .mapped and .class == env.SCREENSAVER)' >/dev/null; do
IFS= read -r -u "$events" line || exit
done
hypr_focus_monitor "$focused"
) >/dev/null 2>&1 &
+2 -8
View File
@@ -1,12 +1,6 @@
#!/bin/bash #!/bin/bash
# omarchy:summary=Launch a terminal in the active terminal's current directory # omarchy:summary=Launch a terminal in the active terminal's current directory
# omarchy:args=[--pid=<terminal-pid>] [command...] # omarchy:args=[command...]
pid="" exec setsid uwsm-app -- xdg-terminal-exec --dir="$(omarchy-cmd-terminal-cwd)" "$@"
if [[ ${1:-} == --pid=* ]]; then
pid=${1#--pid=}
shift
fi
exec setsid uwsm-app -- xdg-terminal-exec --dir="$(omarchy-cmd-terminal-cwd "$pid")" "$@"
+2 -6
View File
@@ -3,15 +3,11 @@
# omarchy:summary=Launch a URL as a web app in the default supported browser # omarchy:summary=Launch a URL as a web app in the default supported browser
# omarchy:args=<url> # omarchy:args=<url>
browser=$(omarchy-cmd-default-browser) browser=$(xdg-settings get default-web-browser)
case $browser in case $browser in
google-chrome* | brave* | microsoft-edge* | opera* | vivaldi* | helium*) ;; google-chrome* | brave* | microsoft-edge* | opera* | vivaldi* | helium*) ;;
*) browser="chromium.desktop" ;; *) browser="chromium.desktop" ;;
esac esac
browser_exec=$(sed -n 's/^Exec=\([^ ]*\).*/\1/p' {~/.local,~/.nix-profile,/usr}/share/applications/$browser 2>/dev/null | head -1) exec setsid uwsm-app -- $(sed -n 's/^Exec=\([^ ]*\).*/\1/p' {~/.local,~/.nix-profile,/usr}/share/applications/$browser 2>/dev/null | head -1) --app="$1" "${@:2}"
if ! omarchy-cmd-browser-handoff "$browser_exec" --app="$1" "${@:2}"; then
exec setsid uwsm-app -- $browser_exec --app="$1" "${@:2}"
fi
+19 -126
View File
@@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
# omarchy:summary=Open a generic image selector menu # omarchy:summary=Open a generic image selector menu
# omarchy:args=[--selected <image>] [--print-name] [--show-labels] [--filterable] [--lazy-thumbnails] [--preload] [--cache-only] [--print-rows] <image-dir>... # omarchy:args=[--selected <image>] [--print-name] [--show-labels] [--filterable] [--lazy-thumbnails] [--preload] [--cache-only] <image-dir>...
selected_image="" selected_image=""
print_name=false print_name=false
@@ -11,11 +11,10 @@ lazy_thumbnails=false
prepare_only=false prepare_only=false
preload=false preload=false
cache_only=false cache_only=false
print_rows=false
image_dirs=() image_dirs=()
usage() { usage() {
echo "Usage: omarchy-menu-images [--selected <image>] [--print-name] [--show-labels] [--filterable] [--lazy-thumbnails] [--preload] [--cache-only] [--print-rows] <image-dir>..." echo "Usage: omarchy-menu-images [--selected <image>] [--print-name] [--show-labels] [--filterable] [--lazy-thumbnails] [--preload] [--cache-only] <image-dir>..."
} }
while (( $# > 0 )); do while (( $# > 0 )); do
@@ -57,10 +56,6 @@ while (( $# > 0 )); do
cache_only=true cache_only=true
shift shift
;; ;;
--print-rows)
print_rows=true
shift
;;
--help|-h) --help|-h)
usage usage
exit 0 exit 0
@@ -80,9 +75,8 @@ fi
selection_file=$(mktemp) selection_file=$(mktemp)
done_file=$(mktemp) done_file=$(mktemp)
pending_file=$(mktemp) pending_file=$(mktemp)
pending_video_file=$(mktemp)
rm -f "$done_file" rm -f "$done_file"
trap 'rm -f "$selection_file" "$done_file" "$pending_file" "$pending_video_file"' EXIT trap 'rm -f "$selection_file" "$done_file" "$pending_file"' EXIT
image_dirs_env="" image_dirs_env=""
for dir in "${image_dirs[@]}"; do for dir in "${image_dirs[@]}"; do
@@ -117,8 +111,8 @@ cache_key=$(printf '%s' "$image_dirs_env" | md5sum | cut -d ' ' -f 1)
rows_cache_file="$cache_dir/$cache_key.rows" rows_cache_file="$cache_dir/$cache_key.rows"
rows_signature_file="$cache_dir/$cache_key.signature" rows_signature_file="$cache_dir/$cache_key.signature"
rows_fast_signature_file="$cache_dir/$cache_key.fast-signature" rows_fast_signature_file="$cache_dir/$cache_key.fast-signature"
rows_signature="v4"$'\n' rows_signature="v3"$'\n'
rows_fast_signature="v3"$'\n' rows_fast_signature="v2"$'\n'
rows_cacheable=true rows_cacheable=true
rows_cache_hit=false rows_cache_hit=false
image_files=() image_files=()
@@ -139,25 +133,17 @@ else
image_files+=("$image") image_files+=("$image")
image_signature=$(stat -Lc '%s:%Y' "$image") || continue image_signature=$(stat -Lc '%s:%Y' "$image") || continue
rows_signature+="$image:$image_signature"$'\n' rows_signature+="$image:$image_signature"$'\n'
done < <(find -L "$dir" -maxdepth 1 -type f \ done < <(find -L "$dir" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) -print0 2>/dev/null | sort -z)
\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \
-o -iname '*.mp4' -o -iname '*.m4v' -o -iname '*.mov' -o -iname '*.webm' -o -iname '*.mkv' -o -iname '*.avi' \) \
-print0 2>/dev/null | sort -z)
fi fi
done done
fi fi
is_video_path() {
[[ ${1,,} =~ \.(mp4|m4v|mov|webm|mkv|avi)$ ]]
}
generate_thumbnail() { generate_thumbnail() {
local image="$1" local image="$1"
local thumbnail="$2" local thumbnail="$2"
local lock="$thumbnail.lock" local lock="$thumbnail.lock"
local lock_fd local lock_fd
local tmp="$thumbnail.$$.jpg" local tmp="$thumbnail.$$.jpg"
local thumbnail_command
# Older releases used directories as locks, which could survive a killed # Older releases used directories as locks, which could survive a killed
# generator and block this thumbnail forever. Only reap aged ones, so a # generator and block this thumbnail forever. Only reap aged ones, so a
@@ -175,29 +161,13 @@ generate_thumbnail() {
[[ -f $thumbnail ]] && return [[ -f $thumbnail ]] && return
# Callers fan out one generator per file, so keep each image conversion # Callers fan out one generator per image, so keep each vips single-threaded.
# single-threaded. ffmpegthumbnailer provides a still preview for videos. # Close the lock fd for vips: an orphaned or hung vips must not keep holding
if is_video_path "$image"; then # the lock after this shell is killed.
# Videos are generated before the picker opens, so one unreadable or if VIPS_CONCURRENCY=1 vipsthumbnail "$image" --size 1536x864 --smartcrop=centre --path "$tmp[Q=82,strip]" {lock_fd}>&-; then
# stalled file must not hold it shut. A failed run drops the row.
thumbnail_command=(timeout -k 5 10 ffmpegthumbnailer -i "$image" -o "$tmp" -s 1536 -q 8)
else
thumbnail_command=(env VIPS_CONCURRENCY=1 vipsthumbnail "$image" --size 1536x864 --smartcrop=centre --path "$tmp[Q=82,strip]")
fi
# Close the lock fd for the converter: an orphaned or hung child must not
# keep holding the lock after this shell is killed.
if "${thumbnail_command[@]}" {lock_fd}>&-; then
mv -f "$tmp" "$thumbnail" mv -f "$tmp" "$thumbnail"
else else
status=$?
rm -f "$tmp" "$thumbnail" rm -f "$tmp" "$thumbnail"
# Remember a video the converter rejected, so it costs nothing on the next
# open. The key covers size and mtime, so a repaired file starts clean. A
# timeout is left to retry: the machine may only have been busy.
if is_video_path "$image" && (( status != 124 && status != 137 )); then
: >"$thumbnail.failed"
fi
fi fi
} }
@@ -216,94 +186,30 @@ thumbnail_for() {
thumbnail="$cache_dir/$hash.jpg" thumbnail="$cache_dir/$hash.jpg"
if [[ ! -f $thumbnail ]]; then if [[ ! -f $thumbnail ]]; then
# A video that already failed to convert has no row to offer. Hand the if [[ $lazy_thumbnails == true && $cache_only != true ]]; then
# marker back so the caller can keep the rows uncached over its absence.
if is_video_path "$image" && [[ -f $thumbnail.failed ]]; then
printf '%s' "$thumbnail.failed"
return
fi
# A lazy row stands in with the media file itself, which the picker draws
# with an Image -- fine for a picture, blank for a video. Videos take the
# queue instead, which also keeps them under its narrower fan out.
if [[ $lazy_thumbnails == true && $cache_only != true ]] && ! is_video_path "$image"; then
rows_cacheable=false rows_cacheable=false
if [[ $prepare_only != true ]]; then if [[ $prepare_only != true ]]; then
printf '%s\0%s\0' "$image" "$thumbnail" >>"$pending_file" generate_thumbnail "$image" "$thumbnail" >/dev/null 2>&1 &
fi fi
printf '%s' "$image" printf '%s' "$image"
return return
fi fi
if is_video_path "$image"; then printf '%s\0%s\0' "$image" "$thumbnail" >>"$pending_file"
printf '%s\0%s\0' "$image" "$thumbnail" >>"$pending_video_file"
else
printf '%s\0%s\0' "$image" "$thumbnail" >>"$pending_file"
fi
fi fi
printf '%s' "$thumbnail" printf '%s' "$thumbnail"
} }
# Each vips run is single-threaded. Eager preparation can fill every core; # Generate every queued thumbnail at once; each vips run is single-threaded.
# lazy preparation leaves CPU and I/O capacity for the interactive shell.
# ffmpegthumbnailer leaves FFmpeg's automatic threading on, so a full-width fan
# out of those would put a codec thread pool on every core at once.
drain_pending_thumbnails() { drain_pending_thumbnails() {
local video_jobs image_jobs pending_fd queue_file queue_lock_fd worker_lock_fd [[ -s $pending_file ]] || return 0
export -f generate_thumbnail is_video_path export -f generate_thumbnail
xargs -a "$pending_file" -0 -n 2 -P "$(nproc)" \
if [[ -s $pending_file ]]; then bash -c 'generate_thumbnail "$1" "$2"' _ >/dev/null 2>&1 || true
if [[ $lazy_thumbnails == true && $cache_only != true ]]; then
image_jobs=$(( $(nproc) / 2 ))
(( image_jobs > 0 )) || image_jobs=1
(( image_jobs > 2 )) && image_jobs=2
# Publish jobs before trying to own the pool. A contended refresh leaves
# its jobs for the current owner instead of throwing its queue away.
queue_file="$rows_cache_file.thumbnails.pending"
exec {queue_lock_fd}>"$rows_cache_file.thumbnails.queue.lock" || return
flock "$queue_lock_fd" || return
cat "$pending_file" >>"$queue_file"
exec {queue_lock_fd}>&-
(
exec {worker_lock_fd}>"$rows_cache_file.thumbnails.lock"
flock -n "$worker_lock_fd" || exit 0
exec {queue_lock_fd}>"$rows_cache_file.thumbnails.queue.lock" || exit 1
while true; do
flock "$queue_lock_fd" || exit 1
if [[ ! -s $queue_file ]]; then
# Release ownership while publishing is still locked. A producer
# arriving after the empty check can then start the next pool.
exec {worker_lock_fd}>&-
exec {queue_lock_fd}>&-
break
fi
# Unlink the batch under the queue lock, then drain its open fd.
# Producers can publish the next batch while conversions run.
exec {pending_fd}<"$queue_file" || exit 1
rm -f "$queue_file"
flock -u "$queue_lock_fd"
# Converters must not inherit the publication lock's descriptor.
nice -n 10 ionice -c 3 xargs -0 -n 2 -P "$image_jobs" \
bash -c 'generate_thumbnail "$1" "$2"' _ <&"$pending_fd" {queue_lock_fd}>&-
exec {pending_fd}<&-
done
) >/dev/null 2>&1 &
else
xargs -a "$pending_file" -0 -n 2 -P "$(nproc)" \
bash -c 'generate_thumbnail "$1" "$2"' _ >/dev/null 2>&1 || true
fi
fi
if [[ -s $pending_video_file ]]; then
video_jobs=$(( $(nproc) / 4 ))
(( video_jobs > 0 )) || video_jobs=1
xargs -a "$pending_video_file" -0 -n 2 -P "$video_jobs" \
bash -c 'generate_thumbnail "$1" "$2"' _ >/dev/null 2>&1 || true
fi
} }
if [[ $rows_cache_hit != true && -f $rows_cache_file && -f $rows_signature_file ]] && cmp -s "$rows_signature_file" <(printf '%s' "$rows_signature"); then if [[ $rows_cache_hit != true && -f $rows_cache_file && -f $rows_signature_file ]] && cmp -s "$rows_signature_file" <(printf '%s' "$rows_signature"); then
@@ -313,12 +219,6 @@ elif [[ $rows_cache_hit != true ]]; then
for image in "${image_files[@]}"; do for image in "${image_files[@]}"; do
thumbnail=$(thumbnail_for "$image") thumbnail=$(thumbnail_for "$image")
[[ -n $thumbnail ]] || continue [[ -n $thumbnail ]] || continue
# Cached rows are trusted on the directory's mtime alone, which a file
# repaired in place never changes. Leave them uncached instead.
if [[ $thumbnail == *.failed ]]; then
rows_cacheable=false
continue
fi
if [[ $lazy_thumbnails == true && $cache_only != true && $thumbnail == $image ]]; then if [[ $lazy_thumbnails == true && $cache_only != true && $thumbnail == $image ]]; then
rows_cacheable=false rows_cacheable=false
fi fi
@@ -332,7 +232,7 @@ elif [[ $rows_cache_hit != true ]]; then
drain_pending_thumbnails drain_pending_thumbnails
if [[ -s $pending_file || -s $pending_video_file ]]; then if [[ -s $pending_file ]]; then
pruned="" pruned=""
while IFS=$'\t' read -r row_image row_thumbnail; do while IFS=$'\t' read -r row_image row_thumbnail; do
if [[ ! -e $row_thumbnail ]]; then if [[ ! -e $row_thumbnail ]]; then
@@ -371,13 +271,6 @@ if [[ $cache_only == true || $prepare_only == true ]]; then
exit 0 exit 0
fi fi
# The shell keeps a copy of these rows so its picker can open without waiting
# on this script, and reads them back from stdout to refresh that copy.
if [[ $print_rows == true ]]; then
printf '%s\n' "$rows"
exit 0
fi
# Image rows can contain newlines and tabs, which don't survive positional # Image rows can contain newlines and tabs, which don't survive positional
# shell IPC arguments. Base64-encode for transit; the ImagePicker plugin # shell IPC arguments. Base64-encode for transit; the ImagePicker plugin
# Qt.atob()s on the other side. # Qt.atob()s on the other side.
+1 -4
View File
@@ -210,9 +210,6 @@ hl = setmetatable({
elseif type(bind_dispatcher) == "string" and bind_dispatcher ~= "" then elseif type(bind_dispatcher) == "string" and bind_dispatcher ~= "" then
kind = "exec" kind = "exec"
arg = bind_dispatcher arg = bind_dispatcher
elseif o and o.bind_commands and o.bind_commands[bind_dispatcher] then
kind = "exec"
arg = o.bind_commands[bind_dispatcher]
end end
print(table.concat({ tostring(modmask), opts.description, key, kind, arg }, "\t")) print(table.concat({ tostring(modmask), opts.description, key, kind, arg }, "\t"))
@@ -533,7 +530,7 @@ output_binding_records_uncached() {
keybindings_cache_key() { keybindings_cache_key() {
{ {
printf 'v14\n' printf 'v13\n'
hyprctl devices 2>/dev/null | grep -F 'active keymap:' hyprctl devices 2>/dev/null | grep -F 'active keymap:'
hyprctl binds 2>/dev/null hyprctl binds 2>/dev/null
} | sha256sum | awk '{ print $1 }' } | sha256sum | awk '{ print $1 }'
+2 -32
View File
@@ -12,38 +12,8 @@ package=$1
command=${2:-$1} command=${2:-$1}
bin=${3:-$command} bin=${3:-$command}
# The command name becomes a file name under ~/.local/bin, so a slash in it
# writes the wrapper somewhere else and the rm below deletes somewhere else. A
# leading dot hides it or walks up, and a leading dash makes a name that reads
# as an option to whatever picks it up. Checked before anything is removed or
# written, and kept to those shapes so package names like npm:playwright still
# stand in for the command name.
case "$command" in
*/* | .* | -* | *[[:cntrl:]]*)
echo "omarchy-mise-install: '$command' is not usable as a command name" >&2
exit 1
;;
esac
mkdir -p "$HOME/.local/bin" mkdir -p "$HOME/.local/bin"
# The heredoc below is unquoted, so whatever these hold is written into the
# wrapper as shell source. Quote them the way omarchy-install-and-launch does, so
# a package name carrying shell characters stays one argument instead of running.
printf -v package_arg '%q' "$package"
printf -v bin_arg '%q' "$bin"
# Keep values that are inert inside double quotes in the form existing migrations
# recognize, including mise backend options that %q would unnecessarily escape.
case "$package" in
*'$'* | *'`'* | *'"'* | *'\'* | *'!'* | *[[:cntrl:]]*) ;;
*) package_arg="\"$package\"" ;;
esac
case "$bin" in
*'$'* | *'`'* | *'"'* | *'\'* | *'!'* | *[[:cntrl:]]*) ;;
*) bin_arg="\"$bin\"" ;;
esac
# These tools install and upgrade on first run, so mise's release cooldown would # These tools install and upgrade on first run, so mise's release cooldown would
# hold a new version back for days after it ships. Exported rather than set on # hold a new version back for days after it ships. Exported rather than set on
# the install line alone, so resolving the version to execute agrees with the # the install line alone, so resolving the version to execute agrees with the
@@ -52,8 +22,8 @@ rm -f "$HOME/.local/bin/$command"
cat >"$HOME/.local/bin/$command" <<EOF cat >"$HOME/.local/bin/$command" <<EOF
#!/bin/bash #!/bin/bash
export MISE_MINIMUM_RELEASE_AGE=0 export MISE_MINIMUM_RELEASE_AGE=0
mise use -g --quiet $package_arg || exit 1 mise use -g --quiet "$package" || exit 1
exec mise x $package_arg -- $bin_arg "\$@" exec mise x "$package" -- "$bin" "\$@"
EOF EOF
chmod +x "$HOME/.local/bin/$command" chmod +x "$HOME/.local/bin/$command"
+1 -3
View File
@@ -22,9 +22,7 @@ if [[ -z $interface ]]; then
# menu's visibility gate describe. Fall back to the first connected Wi-Fi # menu's visibility gate describe. Fall back to the first connected Wi-Fi
# device. nmcli localizes state names, so pin the locale, and the prefix # device. nmcli localizes state names, so pin the locale, and the prefix
# match accepts states like "connected (externally)". # match accepts states like "connected (externally)".
if ! route_device=$(ip route get 1.1.1.1 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i == "dev") { print $(i + 1); exit } }'); then route_device=$(ip route get 1.1.1.1 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i == "dev") { print $(i + 1); exit } }')
route_device=""
fi
if [[ -n $route_device && -d /sys/class/net/$route_device/wireless ]]; then if [[ -n $route_device && -d /sys/class/net/$route_device/wireless ]]; then
interface=$route_device interface=$route_device
else else
-134
View File
@@ -1,134 +0,0 @@
#!/bin/bash
# omarchy:summary=Run OpenClaw's setup wizard the way Omarchy needs it: in the terminal, installing the gateway as a user service, and returning when it is done.
# Not bare `openclaw onboard`: as of 2026.9.1 that is the guided flow, which
# ends by running a foreground gateway and handing off to a browser tab, and
# never returns. --install-daemon keeps it to the classic wizard (minimal
# prompts with --flow quickstart) that installs the gateway service, and
# --skip-ui drops its closing Control UI/TUI prompt since whoever called this
# opens one next.
#
# That classic wizard has one wrinkle: with --skip-ui it prints "Onboarding
# complete" and then never exits. Upstream only calls exit(0) when it launched
# the TUI, and the model sign-in leaves an open socket that keeps the process
# alive otherwise. So this script watches for the gateway to answer and then
# stops the wizard. That is safe because every prompt in the quickstart flow
# runs before the gateway service is installed and reachable: by the time the
# dashboard answers, only the closing notes are left.
set -uo pipefail
wizard=(openclaw onboard --flow quickstart --install-daemon --skip-ui)
config=$HOME/.openclaw/openclaw.json
settle_seconds=${OMARCHY_OPENCLAW_ONBOARD_SETTLE_SECONDS:-3}
# Counted from the moment the config exists, i.e. once the wizard has applied
# setup; the prompts before that take as long as the user takes.
gateway_timeout=${OMARCHY_OPENCLAW_ONBOARD_GATEWAY_TIMEOUT:-180}
# Probes never get the terminal. The wizard writes its config while prompts
# remain, a probe of a gateway not yet up then hangs until the timeout, and
# OpenClaw's CLI takes a terminal on stdin out of raw mode as it is killed,
# which leaves the wizard's next prompt echoing keys instead of reading them.
gateway_answers() {
timeout 10 openclaw dashboard --json </dev/null 2>/dev/null | jq -e '.ok == true' >/dev/null 2>&1
}
# A gateway already answering means OpenClaw is set up, and this run must not
# read its own success off state that predates it: the wizard's repair pass
# would be stopped mid-prompt the moment the watcher looked. Nothing to do.
if [[ -f $config ]] && gateway_answers; then
echo "OpenClaw is already set up and its gateway is running." >&2
echo "To change providers or settings, run: openclaw onboard --classic" >&2
exit 0
fi
# Marks when this run began, so a config left behind by an earlier, incomplete
# setup is not mistaken for this run having applied its own: the gateway
# deadline below must not start ticking while the user is still at prompts.
started=$(mktemp)
trap 'rm -f "$started"' EXIT
# Backgrounded so this script can watch it, but with the terminal kept as its
# stdin (bash would otherwise hand a background job /dev/null). Job control is
# off in a script, so it stays in the terminal's foreground process group and
# reads from it freely. Ctrl-C does not reach it directly, though: bash starts
# async children with SIGINT ignored when job control is off, so the INT trap
# below is what turns Ctrl-C into the wizard's exit.
"${wizard[@]}" <&0 &
wizard_pid=$!
stop_wizard() {
kill -TERM "$wizard_pid" 2>/dev/null || true
}
# Any signal at this script, whether Ctrl-C from the terminal or a kill aimed
# at its pid alone, takes the wizard down with it rather than leaving it
# running unwatched.
trap 'stop_wizard' INT TERM HUP
# Whether this run has applied setup: the config exists and is not older than
# the run itself.
config_applied() {
[[ -f $config && ! $started -nt $config ]]
}
# Whether this run's gateway is up: setup applied, and the process answering
# on the gateway's port is the main process of the service the wizard
# installs. Not the dashboard alone: with no config on disk `openclaw
# dashboard --json` still probes the default loopback port, so a gateway left
# behind by something else (an earlier guided onboarding's foreground
# gateway, say) would read as this run's success while the user is still at
# the first prompt. And not the unit being active either: its Type=simple
# counts it active from the fork, before it has found the port taken by such
# an orphan, and the app would then open on the orphan rather than the
# service this run installed.
gateway_ready() {
config_applied || return 1
local json port listener main_pid
json=$(timeout 10 openclaw dashboard --json </dev/null 2>/dev/null) || return 1
jq -e '.ok == true' <<<"$json" >/dev/null 2>&1 || return 1
port=$(jq -r '.port // empty' <<<"$json" 2>/dev/null)
[[ -n $port ]] || return 1
listener=$(ss -ltnpH "sport = :$port" 2>/dev/null | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | head -1)
main_pid=$(systemctl --user show -p MainPID --value openclaw-gateway.service 2>/dev/null)
[[ -n $listener && -n $main_pid && $main_pid != 0 && $listener == "$main_pid" ]]
}
stopped=false
timed_out=false
config_seen_at=
while kill -0 "$wizard_pid" 2>/dev/null; do
sleep 2
if gateway_ready; then
# Let the outro finish printing, then end the process the wizard leaves
# running.
sleep "$settle_seconds"
stop_wizard
stopped=true
break
fi
config_applied || continue
: "${config_seen_at:=$SECONDS}"
if (( SECONDS - config_seen_at >= gateway_timeout )); then
# Setup was applied but the service never came up (port taken, unit
# failing, ...): the wizard would sit in its never-exiting state forever,
# and so would whoever is waiting on this script.
stop_wizard
timed_out=true
break
fi
done
wait "$wizard_pid"
rc=$?
if [[ $timed_out == true ]]; then
echo "OpenClaw's gateway did not come up within ${gateway_timeout}s of setup finishing." >&2
echo "Check it with: openclaw gateway status" >&2
exit 1
fi
# A wizard stopped here after the gateway came up did its job. One that
# exited on its own, including a user who chose "Skip for now" (no config,
# non-zero), keeps its own exit code.
[[ $stopped == true ]] && rc=0
exit "$rc"
+3 -3
View File
@@ -7,15 +7,15 @@
if omarchy-pkg-missing "$@"; then if omarchy-pkg-missing "$@"; then
if (( EUID == 0 )); then if (( EUID == 0 )); then
pacman -S --noconfirm --needed -- "$@" || exit 1 pacman -S --noconfirm --needed "$@" || exit 1
else else
sudo pacman -S --noconfirm --needed -- "$@" || exit 1 sudo pacman -S --noconfirm --needed "$@" || exit 1
fi fi
fi fi
for pkg in "$@"; do for pkg in "$@"; do
# Secondary check to handle states where pacman doesn't actually register an error # Secondary check to handle states where pacman doesn't actually register an error
if ! pacman -Q -- "$pkg" &>/dev/null; then if ! pacman -Q "$pkg" &>/dev/null; then
echo -e "\033[31mError: Package '$pkg' did not install\033[0m" >&2 echo -e "\033[31mError: Package '$pkg' did not install\033[0m" >&2
exit 1 exit 1
fi fi
+2 -2
View File
@@ -4,12 +4,12 @@
# omarchy:args=<packages...> # omarchy:args=<packages...>
if omarchy-pkg-missing "$@"; then if omarchy-pkg-missing "$@"; then
yay -S --noconfirm --needed -- "$@" || exit 1 yay -S --noconfirm --needed "$@" || exit 1
fi fi
for pkg in "$@"; do for pkg in "$@"; do
# Secondary check to handle states where pacman doesn't actually register an error # Secondary check to handle states where pacman doesn't actually register an error
if ! pacman -Q -- "$pkg" &>/dev/null; then if ! pacman -Q "$pkg" &>/dev/null; then
echo -e "\033[31mError: Package '$pkg' did not install\033[0m" >&2 echo -e "\033[31mError: Package '$pkg' did not install\033[0m" >&2
exit 1 exit 1
fi fi
+2 -3
View File
@@ -24,7 +24,6 @@ if [[ -n $pkg_names ]]; then
source omarchy-sudo-keepalive source omarchy-sudo-keepalive
echo "$pkg_names" | sed 's/^/aur\//' | tr '\n' ' ' | xargs yay -S --noconfirm echo "$pkg_names" | sed 's/^/aur\//' | tr '\n' ' ' | xargs yay -S --noconfirm
code=$? sudo updatedb
sudo updatedb --prune-bind-mounts=no --add-prunepaths=/.snapshots omarchy-show-done
omarchy-show-done $code
fi fi
+2 -9
View File
@@ -8,16 +8,9 @@ installed=()
declare -A installed_exact=() declare -A installed_exact=()
declare -A selected=() declare -A selected=()
if ! installed_packages=$(pacman -Qq); then
echo "Unable to query installed packages; no packages were removed." >&2
exit 1
fi
while IFS= read -r package_name; do while IFS= read -r package_name; do
if [[ -n $package_name ]]; then installed_exact[$package_name]=1
installed_exact[$package_name]=1 done < <(pacman -Qq)
fi
done <<< "$installed_packages"
for pkg in "$@"; do for pkg in "$@"; do
if [[ -n ${installed_exact[$pkg]} && -z ${selected[$pkg]} ]]; then if [[ -n ${installed_exact[$pkg]} && -z ${selected[$pkg]} ]]; then
+1 -1
View File
@@ -22,5 +22,5 @@ if [[ -n $pkg_names ]]; then
# Convert newline-separated selections to space-separated for pacman # Convert newline-separated selections to space-separated for pacman
echo "$pkg_names" | tr '\n' ' ' | xargs sudo pacman -S --noconfirm echo "$pkg_names" | tr '\n' ' ' | xargs sudo pacman -S --noconfirm
omarchy-show-done $? omarchy-show-done
fi fi
+1 -1
View File
@@ -4,7 +4,7 @@
# omarchy:args=<packages...> # omarchy:args=<packages...>
for pkg in "$@"; do for pkg in "$@"; do
if ! pacman -Q -- "$pkg" &>/dev/null; then if ! pacman -Q "$pkg" &>/dev/null; then
exit 0 exit 0
fi fi
done done
+1 -1
View File
@@ -4,7 +4,7 @@
# omarchy:args=<packages...> # omarchy:args=<packages...>
for pkg in "$@"; do for pkg in "$@"; do
pacman -Q -- "$pkg" &>/dev/null || exit 1 pacman -Q "$pkg" &>/dev/null || exit 1
done done
exit 0 exit 0
+1 -1
View File
@@ -20,5 +20,5 @@ pkg_names=$(yay -Qqe | fzf "${fzf_args[@]}")
if [[ -n $pkg_names ]]; then if [[ -n $pkg_names ]]; then
# Convert newline-separated selections to space-separated for yay # Convert newline-separated selections to space-separated for yay
echo "$pkg_names" | tr '\n' ' ' | xargs sudo pacman -Rns --noconfirm echo "$pkg_names" | tr '\n' ' ' | xargs sudo pacman -Rns --noconfirm
omarchy-show-done $? omarchy-show-done
fi fi
+2 -2
View File
@@ -11,7 +11,7 @@ usage() {
Usage: omarchy-provision-first-run [--force] Usage: omarchy-provision-first-run [--force]
Run first-login user setup and notification hooks. By default this only runs Run first-login user setup and notification hooks. By default this only runs
once per user. once per user. Use --force to rerun the full sequence and refresh user setup.
USAGE USAGE
} }
@@ -39,7 +39,7 @@ done
state_dir=~/.local/state/omarchy state_dir=~/.local/state/omarchy
FIRST_RUN_DONE="first-run-user" FIRST_RUN_DONE="first-run-user"
if omarchy-done check "$FIRST_RUN_DONE" && (( force == 0 )); then if omarchy-done check "$FIRST_RUN_DONE" && (( force == 0 )); then
echo "First-run already complete." echo "First-run already complete (rerun with --force to refresh)."
exit 0 exit 0
fi fi
+7 -10
View File
@@ -449,7 +449,7 @@ greeter_screen() {
rows=$(stty size 2>/dev/null </dev/tty | awk '{print $1}') rows=$(stty size 2>/dev/null </dev/tty | awk '{print $1}')
[[ $rows =~ ^[0-9]+$ ]] || rows=${LINES:-24} [[ $rows =~ ^[0-9]+$ ]] || rows=${LINES:-24}
tagline="Beautiful, Fun & Agentic Linux by DHH" tagline="Beautiful, Modern & Opinionated Linux by DHH"
hint="Press Return to Start Setup" hint="Press Return to Start Setup"
printf '%s%s' "$HIDE_CURSOR" "$CLEAR" printf '%s%s' "$HIDE_CURSOR" "$CLEAR"
@@ -677,15 +677,11 @@ user_groups() {
if [[ -f $PROVISIONING_DIR/groups ]]; then if [[ -f $PROVISIONING_DIR/groups ]]; then
while IFS= read -r group; do while IFS= read -r group; do
[[ -n $group ]] || continue [[ -n $group ]] || continue
# Never replay old privileged group defaults. Docker is always opt-in. # Never grant docker at first boot, even if an older install recorded it
# Input is only retained when the factory image has one of the features # (or a factory snapshot predating the opt-in default carries it): the
# whose installer deliberately grants access to raw input devices. # docker group is root-equivalent. It is opt-in via
# omarchy-setup-security-sudoless-docker.
[[ $group == "docker" ]] && continue [[ $group == "docker" ]] && continue
if [[ $group == "input" ]] &&
! pacman -Qq xpadneo-dkms &>/dev/null &&
! pacman -Qq ydotool &>/dev/null; then
continue
fi
getent group "$group" >/dev/null || continue getent group "$group" >/dev/null || continue
[[ ",$groups," == *",$group,"* ]] || groups+=",$group" [[ ",$groups," == *",$group,"* ]] || groups+=",$group"
done <"$PROVISIONING_DIR/groups" done <"$PROVISIONING_DIR/groups"
@@ -1013,7 +1009,7 @@ run_provisioning() {
log_step "finalizing user" log_step "finalizing user"
echo finalize >"$STATE_FILE" echo finalize >"$STATE_FILE"
if ! finalize_user; then if ! finalize_user; then
log_step "finalize-user failed (continuing)" log_step "finalize-user failed (continuing; user can retry after login)"
touch "$FINALIZE_WARNING_FLAG" touch "$FINALIZE_WARNING_FLAG"
fi fi
@@ -1092,6 +1088,7 @@ run_setup() {
clear_logo clear_logo
echo echo
say --foreground 1 "User finalization reported errors (see $LOG_FILE)." say --foreground 1 "User finalization reported errors (see $LOG_FILE)."
say --foreground 1 "Run 'omarchy-provision-user --force' after login to retry."
sleep 3 sleep 3
fi fi
+2 -10
View File
@@ -57,7 +57,7 @@ state_dir="$HOME/.local/state/omarchy"
mkdir -p "$state_dir" mkdir -p "$state_dir"
if omarchy-done check finalize-user && (( force == 0 )); then if omarchy-done check finalize-user && (( force == 0 )); then
echo "User finalization already complete." echo "User finalization already complete (rerun with --force to refresh)."
exit 0 exit 0
fi fi
@@ -84,7 +84,7 @@ fi
# Dev-aware skill symlinks. Cannot live in /etc/skel because OMARCHY_PATH may # Dev-aware skill symlinks. Cannot live in /etc/skel because OMARCHY_PATH may
# point at a dev checkout (omarchy dev link) where the target differs. # point at a dev checkout (omarchy dev link) where the target differs.
# Loops every skill directory, so shipping a new one needs no edit here. # Loops every skill directory, so shipping a new one needs no edit here.
mkdir -p ~/.agents/skills ~/.claude/skills ~/.codex/skills ~/.pi/agent/skills ~/.gemini/config/skills ~/.hermes/skills mkdir -p ~/.agents/skills ~/.claude/skills ~/.codex/skills ~/.pi/agent/skills ~/.gemini/config/skills
for skill in "$OMARCHY_PATH"/default/agents/skills/*/; do for skill in "$OMARCHY_PATH"/default/agents/skills/*/; do
skill=${skill%/} skill=${skill%/}
name=${skill##*/} name=${skill##*/}
@@ -93,14 +93,6 @@ for skill in "$OMARCHY_PATH"/default/agents/skills/*/; do
ln -sfn "$skill" ~/.codex/skills/"$name" ln -sfn "$skill" ~/.codex/skills/"$name"
ln -sfn "$skill" ~/.pi/agent/skills/"$name" ln -sfn "$skill" ~/.pi/agent/skills/"$name"
ln -sfn "$skill" ~/.gemini/config/skills/"$name" ln -sfn "$skill" ~/.gemini/config/skills/"$name"
ln -sfn "$skill" ~/.hermes/skills/"$name"
if [[ -d ~/.hermes/profiles ]]; then
for profile in ~/.hermes/profiles/*/; do
[[ -d $profile ]] || continue
mkdir -p "$profile/skills"
ln -sfn "$skill" "$profile/skills/$name"
done
fi
done done
mkdir -p ~/Downloads ~/Pictures ~/Videos ~/.config/gtk-3.0 mkdir -p ~/Downloads ~/Pictures ~/Videos ~/.config/gtk-3.0
+17 -55
View File
@@ -1,64 +1,26 @@
#!/bin/bash -p #!/bin/bash
# omarchy:summary=Overwrite the package configuration for /etc/pacman with the Omarchy default of using its dedicated mirrors and repositories, then update all packages. # omarchy:summary=Overwrite the package configuration for /etc/pacman with the Omarchy default of using its dedicated mirrors and repositories, then update all packages.
# omarchy:requires-sudo=true # omarchy:requires-sudo=true
if [[ $- != *p* ]]; then
echo "Refusing an unsafe Bash startup." >&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 -e
omarchy_security_sanitize_bash_environment "$0" "$@"
omarchy_security_require_source_root "$0"
# Channel switching calls this behind its own sanitized PATH and hands the
# caller's original path over the same way the updater receives it.
user_path=${OMARCHY_UPDATE_USER_PATH:-$PATH}
unset OMARCHY_UPDATE_USER_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
channel="${1:-stable}"
if [[ $channel != "stable" && $channel != "rc" && $channel != "edge" ]]; then
echo "Invalid channel: $channel" >&2
exit 2
fi
# Render the channel templates plus this machine's regional additions as the
# caller, so root only copies finished files. The scratch directory goes on
# every exit, including a failed render, a refused sudo prompt, or a signal;
# revoking the sudo timestamp stays the last thing the exit path does.
rendered=$(mktemp -d)
cleanup_refresh() {
local status=$?
rm -rf "$rendered"
omarchy_security_exit_with_revoked_sudo "$status"
}
trap cleanup_refresh EXIT
"$OMARCHY_PATH/bin/omarchy-apply-pacman" --render "$channel" "$rendered"
sudo cp -f /etc/pacman.conf /etc/pacman.conf.bak sudo cp -f /etc/pacman.conf /etc/pacman.conf.bak
sudo cp -f /etc/pacman.d/mirrorlist /etc/pacman.d/mirrorlist.bak sudo cp -f /etc/pacman.d/mirrorlist /etc/pacman.d/mirrorlist.bak
echo "Setting channel to $channel"
sudo cp -f "$rendered/pacman.conf" /etc/pacman.conf
sudo cp -f "$rendered/mirrorlist" /etc/pacman.d/mirrorlist
rm -rf "$rendered"
# Allow user customization of /etc/pacman.conf before the upgrade runs, so channel="${1:-stable}"
# custom repositories and IgnorePkg entries shape the downgrade-capable
# transaction below. The hook is user code: it runs cold behind the no-update if [[ $channel != "stable" && $channel != "rc" && $channel != "edge" ]]; then
# wrapper, and the timestamp is revoked again afterwards so any authorization echo "Error: Invalid channel '$channel'. Must be one of: stable, rc, edge"
# the hook obtained for itself cannot carry into the transaction. exit 1
omarchy_security_revoke_sudo_timestamp fi
PATH="$OMARCHY_PATH/default/omarchy/sudo-no-update:$user_path" \
"$OMARCHY_PATH/bin/omarchy-hook" pre-refresh-pacman echo "Setting channel to $channel"
omarchy_security_revoke_sudo_timestamp echo
sudo cp -f "$OMARCHY_PATH/default/pacman/pacman-$channel.conf" /etc/pacman.conf
sudo cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-$channel" /etc/pacman.d/mirrorlist
# Allow user customization of /etc/pacman.conf before the upgrade runs
omarchy-hook pre-refresh-pacman
# Reset all package DBs and then update # Reset all package DBs and then update
omarchy-update-pacman -Syyuu --noconfirm sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syyuu --noconfirm
+2 -2
View File
@@ -11,8 +11,8 @@ set -e
omarchy-refresh-pacman omarchy-refresh-pacman
# Downgrade any packages to the stable setup # Downgrade any packages to the stable setup
omarchy-update-pacman -Suu --noconfirm sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Suu --noconfirm
# Ensure all packages are installed # Ensure all packages are installed
mapfile -t packages < <(grep -v '^#' "$OMARCHY_PATH/install/omarchy-base.packages" | grep -v '^$') mapfile -t packages < <(grep -v '^#' "$OMARCHY_PATH/install/omarchy-base.packages" | grep -v '^$')
omarchy-update-pacman -Syu --noconfirm --needed "${packages[@]}" sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm --needed "${packages[@]}"
-23
View File
@@ -1,23 +0,0 @@
#!/bin/bash
# omarchy:summary=Remove the Claude desktop app along with its configuration and caches.
# omarchy:requires-sudo=true
# -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /.
set -euo pipefail
# The app rewrites ~/.config/Claude for as long as it runs, so a removal that
# leaves it running deletes the directory and watches it come straight back.
pkill -x claude-desktop || true
omarchy-pkg-drop claude-desktop
# Not ~/.claude, ~/.claude.json, or ~/.cache/claude-cli-nodejs: those belong
# to the Claude Code CLI, which ships in a different package that survives
# this one. The desktop app keeps its state in its own Electron directories.
rm -rf \
"$HOME/.config/Claude" \
"$HOME/.cache/Claude"
echo ""
echo "Claude has been removed."
-241
View File
@@ -1,241 +0,0 @@
#!/bin/bash
# omarchy:summary=Remove the Hermes desktop app along with the Hermes runtime it installed.
# omarchy:requires-sudo=true
# -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /.
set -euo pipefail
unit_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
# Only systemd's own word counts as "stopped": a non-zero exit from is-active
# also covers an unreachable user manager, which says nothing about whether
# the process is alive.
unit_stopped() {
local state
state=$(systemctl --user is-active "$1" 2>/dev/null) || true
[[ $state == "inactive" || $state == "failed" ]]
}
# Hermes is closed here rather than asked to be: the agent in its terminal,
# the desktop app and its helpers, a gateway run by hand -- every process
# whose program, by its executable or by what it was started as, lives in the
# runtime about to be deleted or in the package; for an interpreter that is
# the script it runs, or Hermes's own package named with -m, so a gateway
# started by hand as `python .../hermes` or `python -m hermes_cli.main` is
# found too. Judged by the program alone, never by a later argument: an
# editor opened on a file in the runtime is the user's. `check` refuses first if anything else has Hermes's files open, by
# its executable, its working directory or a descriptor, that editor or a
# shell sitting in ~/.hermes, so a refusal touches nothing; that
# one is the user's to close, because removing an open SQLite WAL leaves a
# live writer on a deleted generation. `stop` then ends Hermes's own and
# refuses again over whatever is left.
hermes_holders() {
python3 - "$1" "$HOME" <<'PY'
import os
import re
import signal
import sys
import time
from pathlib import Path
mode, home = sys.argv[1], Path(sys.argv[2])
roots = [str((home / relative).resolve()) for relative in ('.hermes', '.config/Hermes')]
roots.append('/opt/hermes-desktop')
runtime = [str((home / relative).resolve()) for relative in ('.hermes/hermes-agent', '.hermes/node', '.hermes/bin')]
runtime.append('/opt/hermes-desktop')
def under(target, directories):
target = target.removesuffix(' (deleted)')
return any(target == directory or target.startswith(directory + '/') for directory in directories)
def processes():
found = []
for process in Path('/proc').iterdir():
if not process.name.isdigit() or int(process.name) == os.getpid():
continue
try:
if process.stat().st_uid != os.getuid():
continue
argv = [os.fsdecode(arg) for arg in (process / 'cmdline').read_bytes().split(b'\0')]
program = []
try:
program.append(os.readlink(process / 'exe'))
except OSError:
pass
touched = list(program)
try:
touched.append(os.readlink(process / 'cwd'))
except OSError:
pass
try:
for entry in (process / 'fd').iterdir():
try:
touched.append(os.readlink(entry))
except OSError:
pass
except PermissionError:
pass
started_as = argv[:1]
interpreter = bool(started_as) and re.fullmatch(r'(python[0-9.]*|node|bash|sh)', os.path.basename(started_as[0]))
if interpreter:
started_as = argv[:2]
# Hermes's own package, run as a module from an activated venv.
ours = any(under(target, runtime) for target in started_as + program) or \
(bool(interpreter) and argv[1:3] and argv[1] == '-m' and argv[2].startswith('hermes_cli'))
if ours or any(under(target, roots) for target in touched):
try:
name = (process / 'comm').read_text().strip()
except OSError:
name = '?'
found.append((int(process.name), ours, name))
except (FileNotFoundError, ProcessLookupError, PermissionError):
continue
return found
def named(entries):
return ', '.join(f'{pid} {name}' for pid, ours, name in entries)
def refuse(entries):
print('Something other than Hermes still has its files open (PIDs: ' + named(entries)
+ '). Close it, then run the removal again.', file=sys.stderr)
sys.exit(1)
strangers = [entry for entry in processes() if not entry[1]]
if strangers:
refuse(strangers)
if mode == 'check':
sys.exit(0)
mine = [entry for entry in processes() if entry[1]]
if mine:
print('Stopping Hermes (PIDs: ' + named(mine) + ')...')
for signum, patience in ((signal.SIGTERM, 10), (signal.SIGKILL, 3)):
for pid, ours, name in mine:
try:
os.kill(pid, signum)
except ProcessLookupError:
pass
deadline = time.monotonic() + patience
while mine and time.monotonic() < deadline:
time.sleep(0.2)
mine = [entry for entry in processes() if entry[1]]
if not mine:
break
if mine:
print('Hermes did not stop (PIDs: ' + named(mine) + '). Close it, then run the removal again.', file=sys.stderr)
sys.exit(1)
left = processes()
if left:
refuse(left)
PY
}
hermes_holders check
# The gateway unit that upstream's `hermes gateway install` writes starts the
# runtime about to be deleted, so it goes with it, and it is stopped before
# the processes are, or systemd would start the gateway again the moment it
# was killed. Judged by what the unit starts, as the processes are: a unit
# whose ExecStart runs this runtime is its own whatever home it serves, and
# one that runs a Hermes kept elsewhere is somebody else's arrangement even
# when its home sits under ~/.hermes. A unit that will not stop aborts the
# removal: dropping the package would strand a live gateway on deleted code
# with no way to restart it cleanly.
for unit_file in "$unit_dir"/hermes-gateway*.service; do
[[ -f $unit_file ]] || continue
grep '^ExecStart=' "$unit_file" | grep -qF "$HOME/.hermes/hermes-agent/" || continue
unit=${unit_file##*/}
if systemctl --user disable --now "$unit" 2>/dev/null || unit_stopped "$unit"; then
# .bak is what `gateway install --force` leaves behind when it rewrites a
# unit, so it goes with the unit.
rm -f "$unit_file" "$unit_file.bak" "$unit_dir/default.target.wants/$unit"
systemctl --user daemon-reload 2>/dev/null || true
# A unit that had been failing stays listed as "not-found failed" after
# its file is gone until its failed state is reset.
systemctl --user reset-failed "$unit" 2>/dev/null || true
else
echo "Could not stop $unit; Hermes was not removed." >&2
exit 1
fi
done
# The installer leaves a unit waiting to hand the app the Omarchy theme; it
# runs Hermes itself to do so, so it is stopped before the sweep reaches it.
systemctl --user stop omarchy-hermes-theme.service 2>/dev/null || true
hermes_holders stop
omarchy-pkg-drop hermes-desktop
# Upstream's installer writes this when the runtime under ~/.hermes has landed,
# whether Omarchy ran it for the app or the app's own first launch did, and it
# is the only thing that tells that runtime apart from one the user installed
# themselves -- the paths are the same either way. Without it the install never
# got that far: a machine where the package landed but nothing set Hermes up
# still has whatever was there before, and none of it is ours to delete unasked.
if [[ -f $HOME/.hermes/hermes-agent/.hermes-bootstrap-complete ]]; then
# The checkout and venv, its own uv, its own node. None of it is any use once
# the app is gone, so it goes without asking; what the user made with the app
# is a different question, answered below.
rm -rf \
"$HOME/.hermes/hermes-agent" \
"$HOME/.hermes/bootstrap-cache" \
"$HOME/.hermes/bin" \
"$HOME/.hermes/node"
# Only the wrappers pointing into ~/.hermes, matched as a plain string: the
# path carries a dot, so an unanchored pattern would also claim a wrapper
# pointing at a sibling like ~/xhermes.
for command in hermes hermes-agent hermes-acp; do
wrapper="$HOME/.local/bin/$command"
if [[ -f $wrapper && ! -L $wrapper ]] && grep -qF "$HOME/.hermes" "$wrapper"; then
rm -f "$wrapper"
fi
done
# When Hermes brought its own Node it symlinked these next to its own commands,
# and they point at what we just deleted. Only the links into ~/.hermes: a
# system Node, or someone else's, lives somewhere else entirely.
for command in node npm npx; do
link="$HOME/.local/bin/$command"
if [[ -L $link && $(readlink "$link") == "$HOME/.hermes"/* ]]; then
rm -f "$link"
fi
done
fi
# What survives to here is the user's: the chats, memories and skills in
# ~/.hermes, the connections and their encrypted tokens in ~/.config/Hermes.
# Keeping them stays the default -- they are small, and finding them intact
# after a reinstall is the better surprise -- but a removal meant to be
# complete should not leave credentials behind either, so the choice is put in
# front of the user with the size, default no. Asked whenever the directories
# exist, marker or no marker: on a machine where the marker never appeared the
# data came from the terminal CLI or an install the app never finished, and it
# is still what removal is asked to clean up. Naming the paths keeps the
# question honest there too -- ~/.hermes may still carry a runtime the app
# never owned, a yes takes that with it, and saying so is the prompt's job.
# Without a terminal to ask in, keeping everything is the answer.
data_removed=false
if [[ -d $HOME/.hermes || -d $HOME/.config/Hermes ]] && [[ -t 0 ]]; then
# du answers non-zero when either directory is missing, and pipefail would
# turn that into an aborted removal; the size is worth no such thing.
size=$(du -shc "$HOME/.hermes" "$HOME/.config/Hermes" 2>/dev/null | tail -1 | cut -f1 || true)
if gum confirm --default=false "Also delete ~/.hermes and ~/.config/Hermes ($size: chats, memories, skills, connections and tokens)?"; then
hermes_holders stop
rm -rf "$HOME/.hermes" "$HOME/.config/Hermes"
data_removed=true
fi
fi
echo ""
echo "Hermes Desktop has been removed."
if [[ $data_removed == true ]]; then
echo "Its chats, memories, and settings in ~/.hermes and ~/.config/Hermes are gone too."
elif [[ -d $HOME/.hermes || -d $HOME/.config/Hermes ]]; then
echo "Your chats, memories, and skills are still in ~/.hermes,"
echo "and your connections and settings in ~/.config/Hermes."
fi
-88
View File
@@ -1,88 +0,0 @@
#!/bin/bash
# omarchy:summary=Remove the OpenClaw agent platform along with its gateway service and web app.
# omarchy:requires-sudo=true
# -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /.
set -euo pipefail
unit_dir="$HOME/.config/systemd/user"
# Only systemd's own word counts as "stopped": a non-zero exit from is-active
# also covers an unreachable user manager, which says nothing about whether
# the process is alive.
unit_stopped() {
local state
state=$(systemctl --user is-active "$1" 2>/dev/null) || true
[[ $state == inactive || $state == failed ]]
}
# The user services OpenClaw installs for itself: the gateway through
# onboarding, and the node host if the user ever paired this machine to
# another gateway. OpenClaw writes them to $HOME/.config regardless of
# XDG_CONFIG_HOME, so look exactly there; no unit means nothing registered.
# Upstream's own teardown knows every piece its install wrote (unit file, the
# default.target.wants enablement symlink, the reload), so prefer it while the
# binary is still installed and fall back to doing the same by hand. A service
# that will not stop aborts the removal: dropping the package would strand the
# live process on deleted code with no way to restart it cleanly.
for unit_file in "$unit_dir"/openclaw-gateway.service "$unit_dir"/openclaw-node.service; do
[[ -f $unit_file ]] || continue
unit=${unit_file##*/}
role=${unit#openclaw-}
role=${role%.service}
if openclaw "$role" uninstall >/dev/null 2>&1 ||
systemctl --user disable --now "$unit" 2>/dev/null ||
unit_stopped "$unit"; then
# .bak is what `gateway install --force` leaves behind when it rewrites a
# unit, and .reconcile-*.bak what `openclaw update` leaves when it
# refreshes one, so they go with the unit.
rm -f "$unit_file" "$unit_file.bak" "$unit_file".reconcile-*.bak "$unit_dir/default.target.wants/$unit"
systemctl --user daemon-reload 2>/dev/null || true
# A unit that had been failing stays listed as "not-found failed" after
# its file is gone until its failed state is reset.
systemctl --user reset-failed "$unit" 2>/dev/null || true
else
echo "Could not stop $unit; OpenClaw was not removed." >&2
exit 1
fi
done
omarchy-pkg-drop openclaw
# The command on PATH goes, so nothing answers to openclaw. The OpenClaw it
# pointed at stays in ~/.openclaw with the state below: its own updates may
# have migrated that state past the packaged release, which could not open it
# again, and a reinstall picks the kept copy back up instead of seeding one.
if [[ -L $HOME/.local/bin/openclaw && $(readlink -- "$HOME/.local/bin/openclaw") == "$HOME/.openclaw/bin/openclaw" ]]; then
rm -f "$HOME/.local/bin/openclaw"
fi
# The web app launcher and icon omarchy-install-ai-openclaw created.
rm -f "$HOME/.local/share/applications/OpenClaw.desktop"
rm -f "$HOME/.local/share/icons/hicolor/256x256/apps/openclaw.png"
gtk-update-icon-cache "$HOME/.local/share/icons/hicolor" &>/dev/null || true
# ~/.openclaw stays unless asked: the chats, memories, and credentials in
# there are the user's agent, and finding them intact after a reinstall is the
# better surprise. But it also holds the plugin runtimes and caches OpenClaw
# downloads for itself, easily hundreds of megabytes, so the choice is put in
# front of the user with the size rather than left silent. Without a terminal
# to ask in, keeping it is the answer. The snapshots `openclaw update` takes
# of that state sit beside it and go with it.
state_removed=false
if [[ -d $HOME/.openclaw && -t 0 ]]; then
size=$(du -sh "$HOME/.openclaw" 2>/dev/null | cut -f1)
if gum confirm --default=false "Also delete ~/.openclaw ($size: chats, memories, credentials, downloaded plugins, and OpenClaw itself)?"; then
rm -rf "$HOME/.openclaw" "$HOME/.openclaw.update-captures"
state_removed=true
fi
fi
echo ""
echo "OpenClaw has been removed."
if [[ $state_removed == true ]]; then
echo "Its chats, memories, and settings in ~/.openclaw are gone too."
elif [[ -d $HOME/.openclaw ]]; then
echo "Your agent's chats, memories, and settings are still in ~/.openclaw."
fi
Loaded 100 of 677 files, more files were not shown because too many files have changed in this diff. Show more