Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d897cff7bf |
No files matched your search
@@ -0,0 +1,2 @@
|
||||
# Merges to protected branches need sign-off from an org owner.
|
||||
* @dhh @ryanrhughes
|
||||
@@ -1,7 +1,7 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Suggestion
|
||||
url: https://github.com/omacom/omarchy/discussions/categories/suggestions
|
||||
url: https://github.com/basecamp/omarchy/discussions/categories/suggestions
|
||||
about: Suggest a new feature, change to existing feature, or other ideas in Discussions.
|
||||
- name: Support
|
||||
url: https://omarchy.org/discord
|
||||
|
||||
@@ -2,5 +2,3 @@
|
||||
# Python bytecode (orchestrator)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
.claude/
|
||||
@@ -28,7 +28,7 @@ Three documentation trees, split by genre and audience:
|
||||
- Prefer `(( ))` over numeric operators inside `[[ ]]` (e.g., `(( count < 50 ))`, not `[[ $count -lt 50 ]]`)
|
||||
- Prefer a full `if`/`else` conditional for simple two-path control flow; don't rely on `exec` or `exit` in one branch to make following statements unreachable
|
||||
- For strings/paths with spaces, quote them instead of escaping spaces with `\ ` (e.g., `"$APP_DIR/Disk Usage.desktop"`, not `$APP_DIR/Disk\ Usage.desktop`)
|
||||
- Shebangs must use `#!/bin/bash` consistently (never `#!/usr/bin/env bash`). A security-sensitive entrypoint may use the exact `#!/bin/bash -p` form only when it must suppress `BASH_ENV` and exported-function startup injection before its first command; that exception must be explained at the boundary and covered by a regression that rejects an ordinary Bash launch with a decoy `-p` argument.
|
||||
- Shebangs must use `#!/bin/bash` consistently (never `#!/usr/bin/env bash`)
|
||||
- Scripts under `install/` and `migrations/` may be sourced and intentionally omit shebangs
|
||||
|
||||
# Command Naming
|
||||
@@ -63,30 +63,6 @@ guidance does not drift from the router.
|
||||
- `$OMARCHY_PATH` is set at the top level by the uwsm session environment and is always available to Omarchy runtime code.
|
||||
- Commands in `bin/` and Quickshell QML should rely on `$OMARCHY_PATH` / `Quickshell.env("OMARCHY_PATH")`; do not derive fallback paths from `HOME`, `Quickshell.shellDir`, or re-export/default `OMARCHY_PATH` manually.
|
||||
|
||||
# Platforms
|
||||
|
||||
Omarchy runs on x86 and aarch64 (ARM). A platform name is the CPU architecture, then a hardware family only when that family needs handling of its own:
|
||||
|
||||
- `x86` - every x86_64 machine
|
||||
- `aarch64` - every ARM machine without a family of its own (Snapdragon laptops, Raspberry Pis and ARM VMs today)
|
||||
- `aarch64-apple` - Apple Silicon Macs
|
||||
|
||||
A new family is named `aarch64-<family>` (e.g. `aarch64-qualcomm`, `aarch64-n1x`) and is added only once it needs code of its own, so `aarch64*` always means ARM. `omarchy-hw-platform` prints the most specific name. Use these exact names wherever a platform is named: package lists, pacman template directories, image manifests, dispatch registration, tests and docs. Never introduce `generic`, `arm64`, `apple-silicon` or other spellings; the old `apple-silicon`, `generic-aarch64` and `generic` are read only for compatibility with older image builders.
|
||||
|
||||
Ask a helper; don't read `uname -m` or the device tree in feature code:
|
||||
|
||||
- `omarchy-hw-x86` / `omarchy-hw-aarch64` - the CPU architecture. Use them for binary and ABI availability: a package with no aarch64 build, an x86 guest VM, multilib.
|
||||
- `omarchy-hw-aarch64-apple` (and later `omarchy-hw-aarch64-<family>`) - built on `omarchy-hw-platform`, image-build aware. Use them for hardware behaviour. `omarchy-hw-apple-silicon` is a hidden alias kept for the omarchy-mac packages; don't use it in new code.
|
||||
|
||||
Where platform code lives:
|
||||
|
||||
- Packages: `install/omarchy-base.packages` for everyone, `install/omarchy-aarch64.packages` for every ARM machine, `install/omarchy-<platform>.packages` for one family, and base packages with no aarch64 build in `install/omarchy-x86_64-only.packages`. `omarchy-pkg-defaults` composes them.
|
||||
- Pacman templates: `default/pacman/` for x86, `default/pacman/<platform>/` for each ARM platform.
|
||||
- Hardware setup: `install/hardware/<vendor-or-family>/`, gated by a predicate. `install/hardware/apple/` holds Intel Mac quirks; Apple Silicon's own setup ships in the omarchy-mac packages.
|
||||
- Boot chains Omarchy can't drive generically: a platform package behind `omarchy-lifecycle-dispatch` ([`docs/lifecycle-dispatch.md`](docs/lifecycle-dispatch.md)). Platforms that boot Limine with UKIs like x86 keep the generic path.
|
||||
|
||||
The experience should be the same on every platform. When a package or feature is missing or broken on one, the default is to get it fixed upstream (an aarch64 build, a bug report, a patch to the maintainer) and leave the gap visible meanwhile, not to paper over it. When asked for a platform workaround, say so and propose the upstream fix first. Hiding a menu entry or skipping a package is fine only for things that can't exist there (an x86 guest VM, an x86-only proprietary binary). Alternative packages and platform-only replacements are a last resort, used only when upstream can't or won't fix it, with the reason written next to the code.
|
||||
|
||||
# Privileged Commands
|
||||
|
||||
- Follow the "Privilege Escalation" section of `default/agents/skills/omarchy/SKILL.md`. It draws the
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# Omarchy
|
||||
|
||||
Omarchy is a beautiful, fun & agentic Linux distribution by DHH.
|
||||
Omarchy is a beautiful, modern & opinionated Linux distribution by DHH.
|
||||
|
||||
Read more at [omarchy.org](https://omarchy.org).
|
||||
|
||||
## The Omarchy Manual
|
||||
|
||||
The manual lives in [`manual/`](manual/), which is its authoritative source.
|
||||
The manual lives in [`manual/`](manual/), which is its authoritative source. It's
|
||||
mirrored to [learn.omacom.io](https://learn.omacom.io/2/the-omarchy-manual), where
|
||||
its screenshots are also hosted.
|
||||
|
||||
- [Welcome to Omarchy!](manual/01-welcome-to-omarchy.md)
|
||||
|
||||
|
||||
@@ -70,6 +70,10 @@ magick -background white -fill black -font default/fonts/omarchy/omarchy.ttf \
|
||||
-pointsize 110 label:@/tmp/row.txt /tmp/font-row.png
|
||||
```
|
||||
|
||||
Then confirm it in the running menu per [`visual-verification.md`](visual-verification.md). Avoid leaving two fonts with the `omarchy` family registered: Qt can use an old copy in `~/.local/share/fonts` even when `fc-match omarchy` reports the packaged font. For a preview, either replace the packaged file in the disposable VM or temporarily exclude it with a `<rejectfont>` rule in `~/.config/fontconfig/conf.d/` before loading the candidate.
|
||||
|
||||
Refresh the font cache and restart the shell afterwards — Qt reads the font database at startup, so `omarchy menu refresh` alone will not pick up a changed font. Remove temporary fonts and rules after verification.
|
||||
Then confirm it in the running menu per
|
||||
[`visual-verification.md`](visual-verification.md). Fontconfig prefers the
|
||||
packaged font over a copy in `~/.local/share/fonts` for the same family, so a
|
||||
preview needs either the real file replaced or a `<rejectfont>` rule in
|
||||
`~/.config/fontconfig/conf.d/` pointing fontconfig away from the packaged one.
|
||||
Restart the shell afterwards — Qt reads the font database at startup, so
|
||||
`omarchy menu refresh` alone will not pick up a changed font.
|
||||
@@ -12,10 +12,7 @@ commands and reusable setup leaves:
|
||||
- avoid `exit` in sourced setup scripts unless intentionally aborting setup.
|
||||
- use `$OMARCHY_INSTALL` and `$OMARCHY_PATH` instead of hard-coded Omarchy paths.
|
||||
- keep root-scoped hardware setup under `install/hardware/` and orchestrate it through `install/hardware/all.sh`.
|
||||
- in an image build every `run_logged` leaf in `install/hardware/all.sh` is queued and runs on the machine's first boot instead (`bin/omarchy-provision-hardware`, see `docs/file-layout.md`), possibly offline, using packages already in the image, and before any user exists; keep those leaves idempotent and put nothing but `run_logged` lines in `all.sh`.
|
||||
- gate platform-specific setup on a predicate built on `omarchy-hw-platform` (`omarchy-hw-aarch64-apple`), not on `uname -m`; architecture checks (`omarchy-hw-x86`, `omarchy-hw-aarch64`) are only for ABI, binary availability or a repository's `$arch`. Platform names and where platform code lives are in the Platforms section of `AGENTS.md`.
|
||||
- keep every per-user setup leaf under `install/user/` (including `install/user/hardware/` and `install/user/first-run/`) so it is clear what must run for each user.
|
||||
- The base install supplies matching kernel headers before hardware setup. DKMS installers should install their driver packages and assume the headers exist.
|
||||
- prefer helper commands for package and command checks where available.
|
||||
|
||||
Raw `command -v`, `pacman`, and `pacman-key` are acceptable in package-helper
|
||||
|
||||
@@ -163,8 +163,6 @@ rm ~/.local/state/omarchy/migrations/<migration>.sh
|
||||
omarchy-migrate
|
||||
```
|
||||
|
||||
Keep a dedicated test while the migration is still being written or bugfixed, if it calls an Omarchy helper whose interface can still change, or if it is a security-sensitive privileged repair (FIDO2, leftover installer artifacts, udev, sshd). Once a one-shot rewrite has shipped in a tagged release and is frozen, drop the test even when that rewrite used sudo, pacman, or limine-mkinitcpio. Keep the migration itself for late-updaters. Tests of `omarchy-migrate`, the login notifier, and `omarchy-upgrade-to-quattro` stay.
|
||||
|
||||
Omarchy 4.0 is upgraded through `bin/omarchy-upgrade-to-quattro`, not through the
|
||||
normal migration runner. Do not add compatibility migrations for old installer
|
||||
layouts; put pre-4 package-layout transition work in the upgrade command instead.
|
||||
|
||||
@@ -20,7 +20,9 @@ Run `omarchy-restart-shell` after making changes to QML files.
|
||||
[`docs/omarchy-shell.md`](../../docs/omarchy-shell.md) and
|
||||
`shell/services/PluginRegistry.qml` for the current contract; fields such as
|
||||
`activation` are optional.
|
||||
- Entry-point QML files are `Item`s (not `ShellRoot`), and accept the shell-injected properties `omarchyPath`, `shell`, `manifest`, and `pluginRegistry` / `barWidgetRegistry` as appropriate. First-party plugins receive the host objects. Third-party plugins receive capability-scoped facades: ordinary plugins may look up and control only their own service and lifecycle, built-in clones retain narrow source-specific configuration and UI compatibility, menu plugins receive an application-library facade, and plugins can read detached scalar bar state; full-bar plugins additionally receive detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities must be stamped from trusted first-party manifests, and third-party registry views and bar configuration must be detached snapshots rather than shared objects. These facades reduce accidental authority but are not a same-process QML sandbox: a visual bar widget can walk its parent hierarchy to ordinary host objects. Authentication services must therefore remain outside both `ShellRoot._services` and the host QObject tree. Do not expose authentication services through new third-party-facing properties.
|
||||
- Entry-point QML files are `Item`s (not `ShellRoot`), and accept the
|
||||
shell-injected properties `omarchyPath`, `shell`, `manifest`, and
|
||||
`pluginRegistry` / `barWidgetRegistry` as appropriate.
|
||||
- Panel / overlay / menu plugins must expose `open(payloadJson)` and
|
||||
`close()` lifecycle methods for `shell summon` and `shell hide`.
|
||||
|
||||
@@ -45,7 +47,3 @@ unicode characters. Agent file-editing tools can strip multi-byte codepoints
|
||||
in some positions — do **not** rewrite widget files wholesale through those
|
||||
tools. For glyph fixes, make a targeted edit with the surrounding context, or
|
||||
use a Python script that inserts codepoints via `chr(0xXXXXX)`.
|
||||
|
||||
## Palette references
|
||||
|
||||
Import `qs.Commons as Commons` and use `Commons.Color` for the palette singleton, including `Connections.target`. Qt 6.12 introduces a `Color` type in `QtQuick` that shadows unqualified palette references. This applies to first-party components and third-party plugin QML alike. Keep the unqualified `qs.Commons` import when the file also uses `Style`, `Util`, or `Border`.
|
||||
@@ -15,7 +15,10 @@ Take a full-screen screenshot without opening the editor:
|
||||
omarchy capture screenshot fullscreen save
|
||||
```
|
||||
|
||||
The command writes to Omasnap's configured screenshot directory. Use `omarchy screenshot` for the interactive capture flow, which copies the capture and shows a timed preview. Use `omarchy screenshot --editor=overlay` to test annotation before output. Capture reference and candidate states as separate images when changing a layer-shell surface or layout, then compare both.
|
||||
The command prints the saved path and writes to the configured Pictures
|
||||
directory. Use `omarchy screenshot` for the interactive smart-region flow.
|
||||
Capture reference and candidate states as separate images when changing a
|
||||
layer-shell surface or layout, then compare both.
|
||||
|
||||
Record a short full-screen video for animation, transition, timing, capture, or
|
||||
screen-recording changes:
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Name=Disk Usage
|
||||
Exec=xdg-terminal-exec --app-id=TUI.float -e bash -c "dua i /"
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=disk-usage
|
||||
StartupNotify=true
|
||||
File renamed without changes.
+2
-28
@@ -26,7 +26,7 @@ declare -A ROUTE_IS_ALIAS
|
||||
declare -A BINARY_TO_KEY
|
||||
declare -A GROUP_DESCRIPTIONS
|
||||
|
||||
GROUP_DESCRIPTIONS[agent]="AI coding agents, their usage, and subscription accounts"
|
||||
GROUP_DESCRIPTIONS[agent]="AI coding agent usage data"
|
||||
GROUP_DESCRIPTIONS[ascii]="Text drawn as ASCII art"
|
||||
GROUP_DESCRIPTIONS[audio]="Audio input and output controls"
|
||||
GROUP_DESCRIPTIONS[bar]="Omarchy shell bar layout and settings"
|
||||
@@ -46,7 +46,6 @@ GROUP_DESCRIPTIONS[finalize]="Finalize user setup"
|
||||
GROUP_DESCRIPTIONS[default]="Default application selection"
|
||||
GROUP_DESCRIPTIONS[dev]="Omarchy development tools"
|
||||
GROUP_DESCRIPTIONS[disk]="Disk performance helpers"
|
||||
GROUP_DESCRIPTIONS[dictation]="Dictation recording"
|
||||
GROUP_DESCRIPTIONS[display]="Display and text scaling"
|
||||
GROUP_DESCRIPTIONS[dns]="DNS resolver configuration"
|
||||
GROUP_DESCRIPTIONS[drive]="Drive selection and encryption"
|
||||
@@ -66,7 +65,6 @@ GROUP_DESCRIPTIONS[monitor]="Monitor status helpers"
|
||||
GROUP_DESCRIPTIONS[network]="Network status helpers"
|
||||
GROUP_DESCRIPTIONS[notification]="Notification helpers"
|
||||
GROUP_DESCRIPTIONS[mise]="Mise tool wrappers"
|
||||
GROUP_DESCRIPTIONS[openclaw]="OpenClaw agent platform setup"
|
||||
GROUP_DESCRIPTIONS[osd]="On-screen display status helpers"
|
||||
GROUP_DESCRIPTIONS[pkg]="Package management helpers"
|
||||
GROUP_DESCRIPTIONS[plugin]="Omarchy shell plugin and bar widget management"
|
||||
@@ -74,6 +72,7 @@ GROUP_DESCRIPTIONS[plymouth]="Plymouth boot theme management"
|
||||
GROUP_DESCRIPTIONS[power]="Power supply detection"
|
||||
GROUP_DESCRIPTIONS[powerprofiles]="Power profile management"
|
||||
GROUP_DESCRIPTIONS[refresh]="Reset config to defaults"
|
||||
GROUP_DESCRIPTIONS[region]="Region-specific sources and input method"
|
||||
GROUP_DESCRIPTIONS[reinstall]="Reinstall and reset workflows"
|
||||
GROUP_DESCRIPTIONS[reminder]="Desktop notification reminders"
|
||||
GROUP_DESCRIPTIONS[remove]="Removal workflows"
|
||||
@@ -372,20 +371,6 @@ command_requires_args() {
|
||||
[[ -n $required ]]
|
||||
}
|
||||
|
||||
# A command whose documented arguments are all flags can't take a word, so a
|
||||
# word after it names something else: `omarchy agent account` is the account
|
||||
# commands, not an argument to omarchy-agent.
|
||||
command_takes_only_flags() {
|
||||
local key="$1"
|
||||
local args="${COMMAND_ARGS[$key]}"
|
||||
local token=""
|
||||
|
||||
[[ -n $args ]] || return 1
|
||||
for token in $args; do
|
||||
[[ $token == -* || $token == "[-"* ]] || return 1
|
||||
done
|
||||
}
|
||||
|
||||
load_group_commands() {
|
||||
local group="$1"
|
||||
local file=""
|
||||
@@ -971,17 +956,6 @@ dispatch_fast_or_help() {
|
||||
remaining=("${args[@]:DIRECT_RESOLVED_COUNT}")
|
||||
binary_path="$OMARCHY_BIN_DIR/$DIRECT_RESOLVED_BINARY"
|
||||
|
||||
if (( ${#remaining[@]} == 1 || ${#remaining[@]} == 2 )) && [[ ${remaining[0]} != -* ]] &&
|
||||
{ (( ${#remaining[@]} == 1 )) || remaining_has_help_flag "${remaining[1]}"; } &&
|
||||
group_has_child_commands "${DIRECT_RESOLVED_BINARY#omarchy-}-${remaining[0]}"; then
|
||||
load_command_by_binary "$DIRECT_RESOLVED_BINARY"
|
||||
key="${BINARY_TO_KEY[$DIRECT_RESOLVED_BINARY]}"
|
||||
if [[ -n $key ]] && command_takes_only_flags "$key"; then
|
||||
load_commands
|
||||
show_prefix_help "$DIRECT_RESOLVED_ROUTE ${remaining[0]}" && return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if remaining_has_help_flag "${remaining[@]}"; then
|
||||
if ! load_command_by_binary "$DIRECT_RESOLVED_BINARY"; then
|
||||
echo "Binary is missing or not executable: $DIRECT_RESOLVED_BINARY" >&2
|
||||
|
||||
@@ -82,39 +82,10 @@ grok)
|
||||
command=(grok --permission-mode bypassPermissions)
|
||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
||||
;;
|
||||
openclaw)
|
||||
# The launcher owns onboarding and the gateway dance: OpenClaw's terminal UI
|
||||
# must attach to the running gateway (the embedded `openclaw chat` refuses to
|
||||
# start while the gateway owns the state directory). It has no permission
|
||||
# prompts to skip, and --message seeds the session while keeping it
|
||||
# interactive.
|
||||
command=(omarchy-launch-openclaw --tui)
|
||||
[[ -n ${prompt:-} ]] && command+=(--message "$prompt")
|
||||
;;
|
||||
codex)
|
||||
command=(codex --approve-for-me)
|
||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
||||
;;
|
||||
cursor-agent)
|
||||
# --yolo covers commands only; the workspace trust dialog has its own flag.
|
||||
# A one-word prompt naming a subcommand (update, login, help) still runs that
|
||||
# subcommand after a bare --, so the agent subcommand is named outright, and
|
||||
# -- after it keeps a prompt starting with a dash from being read as an option.
|
||||
command=(cursor-agent --yolo --trust)
|
||||
[[ -n ${prompt:-} ]] && command+=(agent -- "$prompt")
|
||||
;;
|
||||
hermes)
|
||||
if [[ -n ${prompt:-} ]]; then
|
||||
command=(env -u HERMES_SESSION_SOURCE hermes chat --yolo --tui "--query=$prompt")
|
||||
else
|
||||
command=(hermes --yolo)
|
||||
fi
|
||||
;;
|
||||
muse)
|
||||
# --approval-mode never skips the tool prompts but keeps Muse's own sandbox.
|
||||
command=(muse --approval-mode never)
|
||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
||||
;;
|
||||
omp)
|
||||
command=(omp --auto-approve)
|
||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
||||
@@ -136,29 +107,6 @@ pi)
|
||||
;;
|
||||
esac
|
||||
|
||||
# A new session uses the current selection rather than its parent's account
|
||||
# pin. Explicit provider homes still override the selection. Carry the new pin
|
||||
# in the command, since terminals launched through uwsm don't inherit our env.
|
||||
case "$agent" in
|
||||
claude) home_env="CLAUDE_CONFIG_DIR" ;;
|
||||
codex) home_env="CODEX_HOME" ;;
|
||||
grok) home_env="GROK_HOME" ;;
|
||||
*) home_env="" ;;
|
||||
esac
|
||||
if [[ -n $home_env ]]; then
|
||||
account_pin="OMARCHY_AGENT_${agent^^}_HOME"
|
||||
if [[ -v $account_pin && ${!home_env:-} == "${!account_pin}" ]]; then
|
||||
unset "$home_env"
|
||||
fi
|
||||
unset "$account_pin"
|
||||
if [[ -z ${!home_env:-} ]]; then
|
||||
home=$(omarchy-agent-account-home "$agent")
|
||||
account_env=(env -u "$home_env" "$account_pin=$home")
|
||||
[[ -n $home ]] && account_env+=("$home_env=$home")
|
||||
command=("${account_env[@]}" "${command[@]}")
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $inline == "true" ]]; then
|
||||
exec "${command[@]}"
|
||||
else
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Sign in to a Claude, Codex, or Grok subscription
|
||||
# omarchy:args=[--check] [--events] [--reauth <account>] [claude|codex|grok] [label]
|
||||
# omarchy:examples=omarchy agent account add claude | omarchy agent account add Work | omarchy agent account add codex Side
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# --check prints, per provider, whether adding one now would be its first
|
||||
# sign-in, an additional account, or not possible yet. --events is for the
|
||||
# agents panel: progress arrives as `@@omarchy <event> <detail>` lines mixed
|
||||
# into the CLI's own output (which carries codes the panel shows), nothing
|
||||
# waits on a terminal, and the result also lands as a notification in case
|
||||
# the panel has closed.
|
||||
# --reauth <account> signs an account that's already here in again, in its
|
||||
# own home, for when its sign-in has lapsed.
|
||||
check=false
|
||||
events=false
|
||||
reauth=""
|
||||
while [[ ${1:-} == --* ]]; do
|
||||
case "$1" in
|
||||
--check) check=true ;;
|
||||
--events) events=true ;;
|
||||
--reauth)
|
||||
reauth="${2:-}"
|
||||
shift
|
||||
;;
|
||||
*) break ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# Whether a provider's own home (~/.claude, ~/.codex, ~/.grok) is signed in.
|
||||
primary_signed_in() {
|
||||
omarchy-agent-account-state list "$1" | jq -e '.[0].accounts | any(.primary and .signedIn)' >/dev/null
|
||||
}
|
||||
|
||||
if [[ $check == "true" ]]; then
|
||||
for p in claude codex grok; do
|
||||
if primary_signed_in "$p"; then
|
||||
echo "$p additional"
|
||||
else
|
||||
echo "$p first"
|
||||
fi
|
||||
done
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# The provider can be left out when it's your default agent:
|
||||
# `omarchy agent account add Work`.
|
||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
|
||||
default=$(omarchy-default-agent)
|
||||
[[ $default == "claude" || $default == "codex" || $default == "grok" ]] && set -- "$default" "$@"
|
||||
fi
|
||||
|
||||
case "${1:-}" in
|
||||
claude) provider="claude"; name="Claude"; site="claude.ai"; package="claude"; home_env="CLAUDE_CONFIG_DIR" ;;
|
||||
codex) provider="codex"; name="Codex"; site="chatgpt.com"; package="codex"; home_env="CODEX_HOME" ;;
|
||||
grok) provider="grok"; name="Grok"; site="x.ai"; package="grok"; home_env="GROK_HOME" ;;
|
||||
*)
|
||||
echo "Usage: omarchy-agent-account-add [--check] [--events] [--reauth <account>] [claude|codex|grok] [label]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
label="${2:-}"
|
||||
|
||||
say() {
|
||||
if [[ $events == "true" ]]; then
|
||||
printf '@@omarchy status %s\n' "$*"
|
||||
else
|
||||
echo "$*"
|
||||
fi
|
||||
}
|
||||
|
||||
finish() {
|
||||
if [[ $events == "true" ]]; then
|
||||
printf '@@omarchy done %s\n' "$1"
|
||||
omarchy-notification-send -g "$1"
|
||||
else
|
||||
echo
|
||||
echo "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
fail() {
|
||||
if [[ $events == "true" ]]; then
|
||||
printf '@@omarchy error %s\n' "$1"
|
||||
omarchy-notification-send -g -u critical "Couldn't add the $name account" "$1"
|
||||
else
|
||||
echo "$1" >&2
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Cancelling from the panel stops the CLI's login too, and leaves no
|
||||
# half-made home behind.
|
||||
home=""
|
||||
browser=""
|
||||
cleanup() {
|
||||
[[ -n $browser ]] && rm -f "$browser"
|
||||
[[ -n $home && -d $home && $home == */.pending/* ]] && rm -rf "$home"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'pkill -TERM -P $$; exit 130' TERM INT
|
||||
|
||||
# Bash holds a trap until a foreground command finishes, and a login waits on
|
||||
# the browser for as long as it takes. Running it in the background behind a
|
||||
# `wait` lets Cancel stop it at once. It keeps this script's stdin, where the
|
||||
# panel sends a pasted code.
|
||||
interruptible() {
|
||||
"$@" <&0 &
|
||||
wait $!
|
||||
}
|
||||
|
||||
if omarchy-cmd-missing "$provider"; then
|
||||
say "Installing the $name CLI…"
|
||||
if [[ $events == "true" ]]; then
|
||||
mise use -g "$package" >/dev/null 2>&1 || fail "Installing the $name CLI didn't finish."
|
||||
else
|
||||
mise use -g "$package" || exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Your main browser is almost certainly signed in to the account you already
|
||||
# have, and a login would quietly reuse it. The CLIs open their login page
|
||||
# through $BROWSER, so this points it at a private window instead.
|
||||
private_browser() {
|
||||
browser=$(mktemp "${XDG_RUNTIME_DIR:-/tmp}/omarchy-account-browser.XXXXXX")
|
||||
printf '#!/bin/bash\nexec omarchy-launch-browser --private "$1"\n' >"$browser"
|
||||
chmod +x "$browser"
|
||||
}
|
||||
|
||||
case "$provider" in
|
||||
claude) login_command=(claude auth login) ;;
|
||||
codex) login_command=(codex login) ;;
|
||||
grok) login_command=(grok login) ;;
|
||||
esac
|
||||
|
||||
# The CLI's own home, whatever this shell has pointed it at.
|
||||
login() {
|
||||
interruptible env -u CLAUDE_CONFIG_DIR -u CODEX_HOME -u GROK_HOME "OMARCHY_AGENT_${provider^^}_HOME=" "${login_command[@]}"
|
||||
}
|
||||
|
||||
# Signing an account that's already here in again, where it lives: the
|
||||
# primary home through the normal browser, an added one through a private
|
||||
# window. Its home is never recreated or removed.
|
||||
if [[ -n $reauth ]]; then
|
||||
if [[ $reauth == ":primary" ]]; then
|
||||
account_home="$HOME/.$provider"
|
||||
else
|
||||
account_home=$(omarchy-agent-account-state homes "$provider" | awk -F'\t' -v id="$reauth" '$1 == id { print $2 }')
|
||||
fi
|
||||
[[ -n $account_home ]] || fail "No $name account named $reauth."
|
||||
|
||||
if [[ $account_home == "$HOME/.$provider" ]]; then
|
||||
say "Sign in to $name again in the browser window that opens."
|
||||
login || fail "The $name sign-in didn't finish."
|
||||
else
|
||||
say "Sign in again as this account in the private window that opens."
|
||||
private_browser
|
||||
interruptible env BROWSER="$browser" "$home_env=$account_home" "${login_command[@]}" || fail "The $name sign-in didn't finish."
|
||||
fi
|
||||
|
||||
omarchy-agent-usage-update --force "$provider" >/dev/null 2>&1 &
|
||||
finish "Signed in to $name again."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# The first subscription signs in where the CLI always looks, through your
|
||||
# normal browser: nothing is signed in there yet that it could be mistaken for.
|
||||
if ! primary_signed_in "$provider"; then
|
||||
say "Sign in to $name in the browser window that opens."
|
||||
if login && primary_signed_in "$provider"; then
|
||||
# The first agent signed in on a machine with none chosen becomes the
|
||||
# default, so `omarchy agent` and the panel's starter prompts have one to
|
||||
# run. A default someone already picked is never touched.
|
||||
if [[ -z $(omarchy-default-agent) ]]; then
|
||||
mkdir -p "$HOME/.config/omarchy/defaults"
|
||||
printf '%s\n' "$provider" >"$HOME/.config/omarchy/defaults/agent"
|
||||
fi
|
||||
omarchy-agent-usage-update --force "$provider" >/dev/null 2>&1 &
|
||||
finish "Signed in to $name."
|
||||
else
|
||||
fail "The $name sign-in didn't finish."
|
||||
fi
|
||||
else
|
||||
if [[ -z $label && $events == "false" && -t 0 ]]; then
|
||||
label=$(gum input --placeholder "Work" --prompt "Name this $name account: ") || exit 130
|
||||
fi
|
||||
|
||||
if [[ $events == "true" ]]; then
|
||||
say "Sign in as the account you're adding in the private window that opens."
|
||||
else
|
||||
cat <<EOF
|
||||
|
||||
Sign in as the account you're adding in the private window that opens.
|
||||
If a private window was already open, close it first, or $site may reuse whoever it has signed in.
|
||||
|
||||
Don't log out of the $name CLI to do this: logging out revokes the saved sign-in.
|
||||
|
||||
EOF
|
||||
fi
|
||||
|
||||
home=$(omarchy-agent-account-state begin "$provider") || fail "Couldn't set up a home for the new account."
|
||||
|
||||
private_browser
|
||||
interruptible env BROWSER="$browser" "$home_env=$home" "${login_command[@]}"
|
||||
|
||||
if account=$(omarchy-agent-account-state register "$provider" "$label" "$home" 2>&1); then
|
||||
home=""
|
||||
added=$(jq -r '.label + (if .email != "" then " (" + .email + ")" else "" end)' <<<"$account")
|
||||
omarchy-agent-usage-update --force "$provider" >/dev/null 2>&1 &
|
||||
if [[ $events == "true" ]]; then
|
||||
finish "Added $added."
|
||||
else
|
||||
finish "Added $added."
|
||||
echo "Switch to it with: omarchy agent account use $provider $(jq -r .id <<<"$account")"
|
||||
fi
|
||||
else
|
||||
home=""
|
||||
fail "$account"
|
||||
fi
|
||||
fi
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Run Claude, Codex, or Grok as the selected subscription account
|
||||
# omarchy:args=<claude|codex|grok> [args...]
|
||||
# omarchy:hidden=true
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
case "${1:-}" in
|
||||
claude) account_env="CLAUDE_CONFIG_DIR" ;;
|
||||
codex) account_env="CODEX_HOME" ;;
|
||||
grok) account_env="GROK_HOME" ;;
|
||||
*)
|
||||
echo "Usage: omarchy-agent-account-exec <claude|codex|grok> [args...]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
provider=$1
|
||||
shift
|
||||
|
||||
# mise removes its wrapper dispatch directories before calling us, so exec
|
||||
# finds the real CLI (or its first-run installer) rather than this wrapper.
|
||||
# Pin the resolved home for children, including an implicit Main account.
|
||||
# Explicit provider homes still take precedence.
|
||||
if [[ -z ${!account_env:-} ]]; then
|
||||
account_home=$(omarchy-agent-account-home "$provider")
|
||||
export "OMARCHY_AGENT_${provider^^}_HOME=$account_home"
|
||||
if [[ -n $account_home ]]; then
|
||||
export "$account_env=$account_home"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Resolve before restoring dispatch on PATH: mise removes the wrapper paths to
|
||||
# avoid recursion, but the agent's subprocesses need them for other providers.
|
||||
if ! agent_binary=$(type -P "$provider"); then
|
||||
# A mise dispatcher can exist before its CLI or first-run installer does.
|
||||
export MISE_MINIMUM_RELEASE_AGE=0
|
||||
mise use -g --quiet "$provider"
|
||||
agent_binary=$(mise which "$provider")
|
||||
fi
|
||||
export PATH="${MISE_DATA_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/mise}/command-wrappers/bin:$PATH"
|
||||
exec "$agent_binary" "$@"
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Print the config home of the active Claude, Codex, or Grok account
|
||||
# omarchy:args=<claude|codex|grok>
|
||||
# omarchy:hidden=true
|
||||
|
||||
# Prints nothing while the primary account (~/.claude, ~/.codex) is active, so
|
||||
# a launch only sets CLAUDE_CONFIG_DIR / CODEX_HOME when there's somewhere
|
||||
# else to point it. Without a registry, no Python starts at all: this runs on
|
||||
# every `claude` and `codex` typed at a prompt.
|
||||
registry="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/agents/accounts/${1:-}.json"
|
||||
|
||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
|
||||
echo "Usage: omarchy-agent-account-home <claude|codex|grok>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A running session pins its home, including an empty value for Main. Claude's
|
||||
# implicit default also selects ~/.claude.json, so Main must stay implicit.
|
||||
account_pin="OMARCHY_AGENT_${1^^}_HOME"
|
||||
if [[ -v $account_pin ]]; then
|
||||
printf '%s' "${!account_pin}"
|
||||
elif [[ -f $registry ]]; then
|
||||
exec omarchy-agent-account-state home "$1"
|
||||
fi
|
||||
@@ -1,58 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=List Claude, Codex, and Grok subscription accounts and their limits
|
||||
# omarchy:args=[claude|codex|grok] [--json]
|
||||
# omarchy:examples=omarchy agent account list | omarchy agent account list claude --json
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
provider=""
|
||||
json=false
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
claude | codex | grok) provider="$arg" ;;
|
||||
--json) json=true ;;
|
||||
*)
|
||||
echo "Usage: omarchy-agent-account-list [claude|codex|grok] [--json]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
usage_dir="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/agents/usage"
|
||||
accounts=$(omarchy-agent-account-state list $provider) || exit 1
|
||||
|
||||
# Join each account with the limits its provider's usage record last saw. With
|
||||
# a single account the collectors keep its limits at the record's top level.
|
||||
accounts=$(jq -c --arg dir "$usage_dir" '
|
||||
map(. as $p | .accounts |= map(. + {limits: []}))' <<<"$accounts")
|
||||
for p in claude codex grok; do
|
||||
record="$usage_dir/$p.json"
|
||||
[[ -f $record ]] || continue
|
||||
accounts=$(jq -c --arg p "$p" --slurpfile record "$record" '
|
||||
map(if .provider == $p then
|
||||
.accounts |= map(. as $a | . + {limits: (
|
||||
if ($record[0].accounts // []) | length > 0 then
|
||||
[$record[0].accounts[] | select(.id == $a.id) | .limits] | first // []
|
||||
elif $a.primary then
|
||||
$record[0].limits // []
|
||||
else
|
||||
[]
|
||||
end)})
|
||||
else . end)' <<<"$accounts")
|
||||
done
|
||||
|
||||
if [[ $json == "true" ]]; then
|
||||
echo "$accounts"
|
||||
else
|
||||
# A provider nobody has signed in to has nothing to list.
|
||||
for (( i = 0; i < $(jq length <<<"$accounts"); i++ )); do
|
||||
jq -e ".[$i].accounts | any(.signedIn)" <<<"$accounts" >/dev/null || continue
|
||||
jq -r ".[$i] | \"\(.name) — \(.switch) switching at \(.threshold)%\"" <<<"$accounts"
|
||||
jq -r ".[$i].accounts[] |
|
||||
\"\(if .active then \"*\" else \" \" end)\t\(.id)\t\(.label)\t\(.plan)\t\(.email)\t\" +
|
||||
([.limits[]? | select(.percent != null) | \"\(.label): \((.percent * 100) | round)%\"] | join(\", \"))" <<<"$accounts" |
|
||||
column -t -s $'\t' | sed 's/^/ /'
|
||||
echo
|
||||
done
|
||||
fi
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Switch Claude, Codex, or Grok accounts automatically near a limit, or only notify
|
||||
# omarchy:args=[claude|codex|grok] [manual|auto] [threshold]
|
||||
# omarchy:examples=omarchy agent account mode auto | omarchy agent account mode auto 90 | omarchy agent account mode codex manual
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# The provider can be left out when it's your default agent:
|
||||
# `omarchy agent account use work`.
|
||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
|
||||
default=$(omarchy-default-agent)
|
||||
[[ $default == "claude" || $default == "codex" || $default == "grok" ]] && set -- "$default" "$@"
|
||||
fi
|
||||
|
||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
|
||||
echo "Usage: omarchy-agent-account-mode [claude|codex|grok] [manual|auto] [threshold]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n ${2:-} ]]; then
|
||||
omarchy-agent-account-state mode "$@" || exit 1
|
||||
fi
|
||||
|
||||
omarchy-agent-account-state registry "$1" | jq -r '
|
||||
if .switch == "auto" then
|
||||
"\(.name) switches new sessions to the account with the most headroom at \(.threshold)%."
|
||||
else
|
||||
"\(.name) notifies at \(.threshold)% and switches when you say so."
|
||||
end'
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Forget an added Claude, Codex, or Grok account
|
||||
# omarchy:args=[claude|codex|grok] <account>
|
||||
# omarchy:examples=omarchy agent account remove work | omarchy agent account remove codex side
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# The provider can be left out when it's your default agent:
|
||||
# `omarchy agent account use work`.
|
||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
|
||||
default=$(omarchy-default-agent)
|
||||
[[ $default == "claude" || $default == "codex" || $default == "grok" ]] && set -- "$default" "$@"
|
||||
fi
|
||||
|
||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]] || [[ -z ${2:-} ]]; then
|
||||
echo "Usage: omarchy-agent-account-remove [claude|codex|grok] <account>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Removing only deletes the account's home here. It never logs out, which
|
||||
# would revoke the sign-in for every other copy of it too.
|
||||
if [[ -t 0 ]] && ! gum confirm "Forget the $1 account $2? You'd have to sign in again to add it back."; then
|
||||
exit 130
|
||||
fi
|
||||
|
||||
account=$(omarchy-agent-account-state remove "$1" "$2") || exit 1
|
||||
echo "Forgot $(jq -r .label <<<"$account")."
|
||||
# The panel reads which accounts exist from the usage record.
|
||||
omarchy-agent-usage-update --limits-only "$1" >/dev/null 2>&1 &
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Rename a Claude, Codex, or Grok subscription account
|
||||
# omarchy:args=[claude|codex|grok] <account> <name>
|
||||
# omarchy:examples=omarchy agent account rename primary Hey | omarchy agent account rename codex side Side project
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# The provider can be left out when it's your default agent:
|
||||
# `omarchy agent account rename primary Hey`.
|
||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
|
||||
default=$(omarchy-default-agent)
|
||||
[[ $default == "claude" || $default == "codex" || $default == "grok" ]] && set -- "$default" "$@"
|
||||
fi
|
||||
|
||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]] || (( $# < 3 )); then
|
||||
echo "Usage: omarchy-agent-account-rename [claude|codex|grok] <account> <name>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
account=$(omarchy-agent-account-state rename "$@") || exit 1
|
||||
echo "Renamed to $(jq -r .label <<<"$account"). Switch to it with: omarchy agent account use $(jq -r .id <<<"$account")"
|
||||
# The panel reads account names from the usage record.
|
||||
omarchy-agent-usage-update --limits-only "$1" >/dev/null 2>&1 &
|
||||
@@ -1,787 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
# omarchy:summary=Read and change the registry of Claude, Codex, and Grok subscription accounts
|
||||
# omarchy:args=<registry|home|homes|list|refresh|begin|register|use|rename|remove|mode|link|autoswitch> [provider] [args...]
|
||||
# omarchy:hidden=true
|
||||
"""The account registry behind `omarchy agent account`.
|
||||
|
||||
An account is a Claude Code, Codex, or Grok login Omarchy keeps a home for.
|
||||
The login you already have stays where the CLI put it (~/.claude, ~/.codex,
|
||||
~/.grok) and
|
||||
is the primary account; added accounts get a home of their own under
|
||||
~/.local/state/omarchy/agents/accounts/<provider>/<id>/ that holds only that
|
||||
account's credentials and links everything else back to the primary home, so
|
||||
conversation history, settings and skills stay one set across accounts.
|
||||
|
||||
Each provider's registry is one JSON file beside those homes. It records which
|
||||
account is active — the one new sessions start as — how switching happens, and
|
||||
display-safe identity. Tokens never enter it and never leave this process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime as dt
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
PROVIDERS = {
|
||||
"claude": {
|
||||
"name": "Claude",
|
||||
"home_env": "CLAUDE_CONFIG_DIR",
|
||||
# Everything a session reads or writes that isn't tied to the login. A
|
||||
# missing directory is created in the primary home first; a missing file
|
||||
# is linked anyway, so whichever account writes it first writes it there.
|
||||
"shared_dirs": ["projects", "skills", "agents", "commands", "plugins", "hooks", "themes", "output-styles", "file-history", "todos", "plans"],
|
||||
"shared_files": ["settings.json", "CLAUDE.md", "history.jsonl", "keybindings.json"],
|
||||
# Keys copied from the primary's ~/.claude.json into a new account's, so a
|
||||
# second login keeps MCP servers, folder trust and onboarding choices.
|
||||
"carried_keys": ["mcpServers", "projects", "theme", "hasCompletedOnboarding", "lastOnboardingVersion", "editorMode"],
|
||||
},
|
||||
"codex": {
|
||||
"name": "Codex",
|
||||
"home_env": "CODEX_HOME",
|
||||
"shared_dirs": ["sessions", "archived_sessions", "prompts", "skills", "rules", "plugins"],
|
||||
"shared_files": ["config.toml", "AGENTS.md", "history.jsonl", "hooks.json"],
|
||||
"carried_keys": [],
|
||||
},
|
||||
"grok": {
|
||||
"name": "Grok",
|
||||
"home_env": "GROK_HOME",
|
||||
"shared_dirs": ["sessions", "skills", "agents", "plugins", "installed-plugins", "marketplace-cache", "hooks", "memory", "memory-v2", "rules", "commands", "workflows", "personas", "worktrees"],
|
||||
"shared_files": ["config.toml", "settings.json", "lsp.json", "pager.toml", "sandbox.toml", "trusted_folders.toml", "statusline.sh", "worktrees.db"],
|
||||
"carried_keys": [],
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_THRESHOLD = 95
|
||||
# Ids an account can't take because something else already answers to them:
|
||||
# `use next` cycles, a provider name is read as the provider when it comes
|
||||
# first, and the menu's Add Account row is `.add`.
|
||||
RESERVED_IDS = {"next", "add", "claude", "codex", "grok", "primary"}
|
||||
SWITCH_MODES = ("manual", "auto")
|
||||
|
||||
|
||||
class AccountError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- paths
|
||||
|
||||
|
||||
def state_root() -> Path:
|
||||
return Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state") / "omarchy" / "agents"
|
||||
|
||||
|
||||
def accounts_root() -> Path:
|
||||
return state_root() / "accounts"
|
||||
|
||||
|
||||
def registry_path(provider: str) -> Path:
|
||||
return accounts_root() / f"{provider}.json"
|
||||
|
||||
|
||||
def usage_record_path(provider: str) -> Path:
|
||||
return state_root() / "usage" / f"{provider}.json"
|
||||
|
||||
|
||||
# The primary home is where the CLI keeps a login when nothing tells it
|
||||
# otherwise. It is deliberately not read from CLAUDE_CONFIG_DIR / CODEX_HOME /
|
||||
# GROK_HOME:
|
||||
# those are how an added account is selected, so honoring them here would make
|
||||
# whichever account launched this command look like the primary.
|
||||
def primary_home(provider: str) -> Path:
|
||||
return Path.home() / f".{provider}"
|
||||
|
||||
|
||||
def account_home(provider: str, account: dict[str, Any]) -> Path:
|
||||
home = str(account.get("home") or "")
|
||||
return Path(home) if home else primary_home(provider)
|
||||
|
||||
|
||||
# Claude keeps its account file beside the config dir for the default home
|
||||
# (~/.claude.json) and inside it for any other.
|
||||
def claude_account_file(home: Path) -> Path:
|
||||
if home == primary_home("claude"):
|
||||
return Path.home() / ".claude.json"
|
||||
return home / ".claude.json"
|
||||
|
||||
|
||||
# --------------------------------------------------------------- identity
|
||||
|
||||
|
||||
def read_json(path: Path) -> Any:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def plan_label(tier: str, subscription: str) -> str:
|
||||
match = re.search(r"max_(\d+x)", tier or "", re.IGNORECASE)
|
||||
if match:
|
||||
return "Max " + match.group(1)
|
||||
if subscription:
|
||||
return subscription[0].upper() + subscription[1:]
|
||||
return ""
|
||||
|
||||
|
||||
def jwt_claims(token: str) -> dict[str, Any]:
|
||||
try:
|
||||
payload = token.split(".")[1]
|
||||
payload += "=" * (-len(payload) % 4)
|
||||
claims = json.loads(base64.urlsafe_b64decode(payload.encode("ascii")))
|
||||
return claims if isinstance(claims, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
# Who a home is signed in as, from files the CLI wrote at login. Only
|
||||
# display-safe fields come out; an empty accountId means nobody is signed in.
|
||||
def identity(provider: str, home: Path) -> dict[str, str]:
|
||||
if provider == "claude":
|
||||
account = (read_json(claude_account_file(home)) or {}).get("oauthAccount") or {}
|
||||
login = (read_json(home / ".credentials.json") or {}).get("claudeAiOauth") or {}
|
||||
return {
|
||||
"accountId": str(account.get("accountUuid") or ""),
|
||||
"email": str(account.get("emailAddress") or ""),
|
||||
"org": str(account.get("organizationName") or ""),
|
||||
"plan": plan_label(str(login.get("rateLimitTier") or ""), str(login.get("subscriptionType") or "")),
|
||||
}
|
||||
|
||||
if provider == "grok":
|
||||
return grok_identity(home)
|
||||
|
||||
auth = read_json(home / "auth.json") or {}
|
||||
tokens = auth.get("tokens") or {}
|
||||
claims = jwt_claims(str(tokens.get("id_token") or ""))
|
||||
openai = claims.get("https://api.openai.com/auth") or {}
|
||||
plan = str(openai.get("chatgpt_plan_type") or "")
|
||||
return {
|
||||
"accountId": str(tokens.get("account_id") or openai.get("chatgpt_account_id") or ""),
|
||||
"email": str(claims.get("email") or ""),
|
||||
"org": "",
|
||||
"plan": plan[0].upper() + plan[1:] if plan else "",
|
||||
}
|
||||
|
||||
|
||||
# Grok keeps one login per issuer in auth.json, and the plan only in the
|
||||
# settings it caches from xAI, as a JSON string inside that file.
|
||||
def grok_identity(home: Path) -> dict[str, str]:
|
||||
auth = read_json(home / "auth.json") or {}
|
||||
login = next((v for v in auth.values() if isinstance(v, dict)), {}) if isinstance(auth, dict) else {}
|
||||
try:
|
||||
settings = json.loads((read_json(home / "settings_cache.json") or {}).get("payload") or "{}").get("settings") or {}
|
||||
except Exception:
|
||||
settings = {}
|
||||
return {
|
||||
"accountId": str(login.get("user_id") or ""),
|
||||
"email": str(login.get("email") or ""),
|
||||
"org": "",
|
||||
"plan": str(settings.get("subscription_tier_display") or ""),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- registry
|
||||
|
||||
|
||||
def slug(label: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "-", label.lower()).strip("-")
|
||||
|
||||
|
||||
def empty_registry(provider: str) -> dict[str, Any]:
|
||||
return {"active": "main", "switch": "manual", "threshold": DEFAULT_THRESHOLD, "alert": "", "accounts": []}
|
||||
|
||||
|
||||
def primary_entry(provider: str) -> dict[str, Any]:
|
||||
entry = {"id": "main", "label": "Main", "home": "", "primary": True}
|
||||
entry.update(identity(provider, primary_home(provider)))
|
||||
return entry
|
||||
|
||||
|
||||
def normalized(provider: str, data: Any) -> dict[str, Any]:
|
||||
registry = empty_registry(provider)
|
||||
if isinstance(data, dict):
|
||||
registry.update({key: data[key] for key in registry if key in data})
|
||||
accounts = [a for a in registry["accounts"] if isinstance(a, dict) and a.get("id")]
|
||||
if not any(a.get("primary") for a in accounts):
|
||||
accounts.insert(0, primary_entry(provider))
|
||||
registry["accounts"] = accounts
|
||||
if registry["switch"] not in SWITCH_MODES:
|
||||
registry["switch"] = "manual"
|
||||
try:
|
||||
registry["threshold"] = max(50, min(100, int(registry["threshold"])))
|
||||
except Exception:
|
||||
registry["threshold"] = DEFAULT_THRESHOLD
|
||||
if not find(registry, registry["active"]):
|
||||
registry["active"] = accounts[0]["id"]
|
||||
return registry
|
||||
|
||||
|
||||
def load(provider: str) -> dict[str, Any]:
|
||||
return normalized(provider, read_json(registry_path(provider)))
|
||||
|
||||
|
||||
def save(provider: str, registry: dict[str, Any]) -> None:
|
||||
path = registry_path(provider)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(path.parent, 0o700)
|
||||
fd, tmp = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent))
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(registry, handle, indent=2)
|
||||
handle.write("\n")
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, path)
|
||||
except Exception:
|
||||
Path(tmp).unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
# Every change is read-modify-write under one lock per provider, so the panel,
|
||||
# the autoswitch run after each usage update, and a command typed at a prompt
|
||||
# can't interleave and drop each other's edits.
|
||||
class Locked:
|
||||
def __init__(self, provider: str):
|
||||
self.provider = provider
|
||||
|
||||
def __enter__(self) -> dict[str, Any]:
|
||||
accounts_root().mkdir(parents=True, exist_ok=True)
|
||||
self.handle = open(accounts_root() / f".{self.provider}.lock", "w")
|
||||
fcntl.flock(self.handle, fcntl.LOCK_EX)
|
||||
self.registry = load(self.provider)
|
||||
return self.registry
|
||||
|
||||
def __exit__(self, kind, value, traceback) -> None:
|
||||
try:
|
||||
if kind is None:
|
||||
save(self.provider, self.registry)
|
||||
finally:
|
||||
fcntl.flock(self.handle, fcntl.LOCK_UN)
|
||||
self.handle.close()
|
||||
|
||||
|
||||
def find(registry: dict[str, Any], account_id: str) -> dict[str, Any] | None:
|
||||
for account in registry["accounts"]:
|
||||
if account.get("id") == account_id:
|
||||
return account
|
||||
# `primary` names the login that was there first, whatever it's called now.
|
||||
if account_id == "primary":
|
||||
for account in registry["accounts"]:
|
||||
if account.get("primary"):
|
||||
return account
|
||||
return None
|
||||
|
||||
|
||||
def active(registry: dict[str, Any]) -> dict[str, Any]:
|
||||
return find(registry, registry["active"]) or registry["accounts"][0]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- homes
|
||||
|
||||
|
||||
# Lay the links that make an added account share everything but its login
|
||||
# with the primary home. Safe to repeat: an existing link or real file is left
|
||||
# as it is, so nothing an account wrote for itself is ever replaced.
|
||||
def link_shared(provider: str, home: Path) -> None:
|
||||
spec = PROVIDERS[provider]
|
||||
primary = primary_home(provider)
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(home, 0o700)
|
||||
for name in spec["shared_dirs"]:
|
||||
(primary / name).mkdir(parents=True, exist_ok=True)
|
||||
for name in spec["shared_dirs"] + spec["shared_files"]:
|
||||
target = home / name
|
||||
if target.exists() or target.is_symlink():
|
||||
continue
|
||||
target.symlink_to(primary / name)
|
||||
|
||||
|
||||
def carry_settings(provider: str, home: Path) -> None:
|
||||
keys = PROVIDERS[provider]["carried_keys"]
|
||||
if not keys:
|
||||
return
|
||||
source = read_json(claude_account_file(primary_home(provider))) or {}
|
||||
path = claude_account_file(home)
|
||||
data = read_json(path) or {}
|
||||
for key in keys:
|
||||
if key in source and key not in data:
|
||||
data[key] = source[key]
|
||||
fd, tmp = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent))
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, indent=2)
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
# A login in progress gets a scratch home beside the real ones, so an
|
||||
# abandoned or duplicate login never shows up as an account.
|
||||
def begin(provider: str) -> Path:
|
||||
pending = accounts_root() / provider / ".pending"
|
||||
pending.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(pending.parent, 0o700)
|
||||
home = Path(tempfile.mkdtemp(prefix="login-", dir=str(pending)))
|
||||
link_shared(provider, home)
|
||||
return home
|
||||
|
||||
|
||||
def register(provider: str, label: str, pending: Path) -> dict[str, Any]:
|
||||
pending = pending.resolve()
|
||||
if pending.parent != (accounts_root() / provider / ".pending").resolve():
|
||||
raise AccountError(f"{pending} is not a pending {PROVIDERS[provider]['name']} login")
|
||||
|
||||
found = identity(provider, pending)
|
||||
if not found["accountId"]:
|
||||
shutil.rmtree(pending, ignore_errors=True)
|
||||
raise AccountError("The login didn't finish, so no account was added.")
|
||||
|
||||
with Locked(provider) as registry:
|
||||
# Each home is asked who it is now, not trusted from when it was added: a
|
||||
# `claude auth login` run by hand in ~/.claude changes the primary.
|
||||
for account in registry["accounts"]:
|
||||
account.update(identity(provider, account_home(provider, account)))
|
||||
for account in registry["accounts"]:
|
||||
if account.get("accountId") == found["accountId"]:
|
||||
shutil.rmtree(pending, ignore_errors=True)
|
||||
raise AccountError(
|
||||
f"That's {account['label']} ({found['email'] or 'already added'}). "
|
||||
"Try again, and sign in as the other account in the private window."
|
||||
)
|
||||
|
||||
label = label.strip() or found["email"].split("@")[0] or "Account"
|
||||
account_id = unique_id(provider, registry, label)
|
||||
|
||||
# Everything that can fail happens while the login is still pending, where
|
||||
# the add command cleans it up, or is undone: a home that isn't in the
|
||||
# registry would keep a sign-in nothing can manage.
|
||||
carry_settings(provider, pending)
|
||||
home = accounts_root() / provider / account_id
|
||||
pending.rename(home)
|
||||
entry = {"id": account_id, "label": label, "home": str(home), "primary": False}
|
||||
entry.update(found)
|
||||
registry["accounts"].append(entry)
|
||||
try:
|
||||
save(provider, registry)
|
||||
except Exception:
|
||||
registry["accounts"].pop()
|
||||
home.rename(pending)
|
||||
raise
|
||||
return entry
|
||||
|
||||
|
||||
# The id an account answers to, from its label. It can't clash with another
|
||||
# account, a routing keyword, or a home left under that name by an account
|
||||
# that has since been renamed.
|
||||
def unique_id(provider: str, registry: dict[str, Any], label: str, keep: dict[str, Any] | None = None) -> str:
|
||||
base = slug(label) or "account"
|
||||
account_id, n = base, 2
|
||||
while True:
|
||||
taken = find(registry, account_id)
|
||||
if account_id not in RESERVED_IDS and (taken is None or taken is keep):
|
||||
if keep is not None or not (accounts_root() / provider / account_id).exists():
|
||||
return account_id
|
||||
account_id, n = f"{base}-{n}", n + 1
|
||||
|
||||
|
||||
# A new label, and the id that follows from it. The home stays where it is:
|
||||
# sessions already running in it hold that path.
|
||||
def rename(provider: str, target: str, label: str) -> dict[str, Any]:
|
||||
label = label.strip()
|
||||
if not label:
|
||||
raise AccountError("Give the account a name")
|
||||
with Locked(provider) as registry:
|
||||
account = find(registry, target)
|
||||
if not account:
|
||||
raise AccountError(f"No {PROVIDERS[provider]['name']} account named {target}")
|
||||
new_id = unique_id(provider, registry, label, keep=account)
|
||||
if not account.get("primary") and not account.get("home"):
|
||||
account["home"] = str(account_home(provider, account))
|
||||
if registry["active"] == account["id"]:
|
||||
registry["active"] = new_id
|
||||
registry["alert"] = ""
|
||||
account["id"] = new_id
|
||||
account["label"] = label
|
||||
return dict(account)
|
||||
|
||||
|
||||
# Save who each home is signed in as now. A login run in a home, through the
|
||||
# panel or the CLI, changes it, and the usage records take their names and
|
||||
# emails from here.
|
||||
def refresh(provider: str) -> None:
|
||||
with Locked(provider) as registry:
|
||||
for account in registry["accounts"]:
|
||||
account.update(identity(provider, account_home(provider, account)))
|
||||
|
||||
|
||||
def use(provider: str, target: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
with Locked(provider) as registry:
|
||||
previous = active(registry)
|
||||
if target == "next":
|
||||
ids = [a["id"] for a in registry["accounts"]]
|
||||
target = ids[(ids.index(previous["id"]) + 1) % len(ids)]
|
||||
account = find(registry, target)
|
||||
if not account:
|
||||
raise AccountError(f"No {PROVIDERS[provider]['name']} account named {target}")
|
||||
registry["active"] = account["id"]
|
||||
registry["alert"] = ""
|
||||
if not account.get("primary"):
|
||||
link_shared(provider, account_home(provider, account))
|
||||
return previous, account
|
||||
|
||||
|
||||
# Whether a running session was started in this home. Removing it would pull
|
||||
# the login out from under that session, which must never be touched.
|
||||
def home_in_use(provider: str, home: Path) -> bool:
|
||||
wanted = f"{PROVIDERS[provider]['home_env']}=".encode()
|
||||
resolved = home.resolve()
|
||||
for environ in Path("/proc").glob("[0-9]*/environ"):
|
||||
try:
|
||||
entries = environ.read_bytes().split(b"\0")
|
||||
except OSError:
|
||||
continue
|
||||
for entry in entries:
|
||||
if entry.startswith(wanted) and Path(entry[len(wanted):].decode(errors="replace")).resolve() == resolved:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def remove(provider: str, target: str) -> dict[str, Any]:
|
||||
with Locked(provider) as registry:
|
||||
account = find(registry, target)
|
||||
if not account:
|
||||
raise AccountError(f"No {PROVIDERS[provider]['name']} account named {target}")
|
||||
if account.get("primary"):
|
||||
raise AccountError(f"{account['label']} is the login in {primary_home(provider)}; it can't be removed here.")
|
||||
home = account_home(provider, account)
|
||||
if home_in_use(provider, home):
|
||||
raise AccountError(f"A running {PROVIDERS[provider]['name']} session uses {account['label']}. Quit it first, then remove the account.")
|
||||
# Only the account's own directory goes. Its links point into the primary
|
||||
# home, and rmtree removes a link without following it.
|
||||
if home.resolve().parent == (accounts_root() / provider).resolve():
|
||||
shutil.rmtree(home, ignore_errors=True)
|
||||
registry["accounts"] = [a for a in registry["accounts"] if a["id"] != account["id"]]
|
||||
if registry["active"] == account["id"]:
|
||||
registry["active"] = registry["accounts"][0]["id"]
|
||||
return account
|
||||
|
||||
|
||||
def set_mode(provider: str, mode: str, threshold: str | None) -> dict[str, Any]:
|
||||
if mode not in SWITCH_MODES:
|
||||
raise AccountError("Switch mode is manual or auto")
|
||||
with Locked(provider) as registry:
|
||||
registry["switch"] = mode
|
||||
if threshold is not None:
|
||||
try:
|
||||
registry["threshold"] = max(50, min(100, int(threshold)))
|
||||
except ValueError:
|
||||
raise AccountError("The threshold is a percentage between 50 and 100")
|
||||
registry["alert"] = ""
|
||||
return dict(registry)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- autoswitch
|
||||
|
||||
|
||||
def window_open(limit: dict[str, Any], now: dt.datetime) -> bool:
|
||||
raw = str(limit.get("resetsAt") or "")
|
||||
if not raw:
|
||||
return True
|
||||
try:
|
||||
resets_at = dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
return True
|
||||
if resets_at.tzinfo is None:
|
||||
resets_at = resets_at.replace(tzinfo=dt.timezone.utc)
|
||||
return resets_at > now
|
||||
|
||||
|
||||
# How spent an account is: its fullest window that hasn't reset yet, in
|
||||
# percent. A window whose reset has passed counts as empty even when the
|
||||
# numbers are stale, since that's exactly what a reset means. None when
|
||||
# nothing is known at all.
|
||||
def peak(limits: Any, now: dt.datetime) -> float | None:
|
||||
if not isinstance(limits, list):
|
||||
return None
|
||||
known = [l for l in limits if isinstance(l, dict) and isinstance(l.get("percent"), (int, float))]
|
||||
if not known:
|
||||
return None
|
||||
open_windows = [float(l["percent"]) * 100 for l in known if window_open(l, now)]
|
||||
return max(open_windows) if open_windows else 0.0
|
||||
|
||||
|
||||
def next_reset(limits: Any, now: dt.datetime) -> dt.datetime | None:
|
||||
times = []
|
||||
for limit in limits if isinstance(limits, list) else []:
|
||||
try:
|
||||
when = dt.datetime.fromisoformat(str(limit.get("resetsAt") or "").replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
continue
|
||||
if when.tzinfo is None:
|
||||
when = when.replace(tzinfo=dt.timezone.utc)
|
||||
if when > now:
|
||||
times.append(when)
|
||||
return min(times) if times else None
|
||||
|
||||
|
||||
# When an account over the threshold drops back under it: once every window
|
||||
# holding it there has reset, which is the latest of those resets. A 10%
|
||||
# session resetting in an hour doesn't help an account whose weekly window is
|
||||
# full for six more days. None when a blocking window never says.
|
||||
def available_at(limits: Any, threshold: float, now: dt.datetime) -> dt.datetime | None:
|
||||
latest = None
|
||||
for limit in limits if isinstance(limits, list) else []:
|
||||
if not isinstance(limit, dict) or not isinstance(limit.get("percent"), (int, float)):
|
||||
continue
|
||||
if float(limit["percent"]) * 100 < threshold or not window_open(limit, now):
|
||||
continue
|
||||
try:
|
||||
when = dt.datetime.fromisoformat(str(limit.get("resetsAt") or "").replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
return None
|
||||
if when.tzinfo is None:
|
||||
when = when.replace(tzinfo=dt.timezone.utc)
|
||||
latest = when if latest is None or when > latest else latest
|
||||
return latest
|
||||
|
||||
|
||||
def duration(delta: dt.timedelta) -> str:
|
||||
minutes = max(1, int(delta.total_seconds() // 60))
|
||||
hours, minutes = divmod(minutes, 60)
|
||||
days, hours = divmod(hours, 24)
|
||||
if days:
|
||||
return f"{days}d {hours}h"
|
||||
if hours:
|
||||
return f"{hours}h {minutes}m"
|
||||
return f"{minutes}m"
|
||||
|
||||
|
||||
# The whole switching policy, with no I/O: given the registry and the per
|
||||
# account limits from the usage record, what should happen. Returns None when
|
||||
# nothing should, or {"action": "switch"|"notify"|"exhausted", ...}.
|
||||
#
|
||||
# Only the active account crossing the threshold starts anything, so auto
|
||||
# mode never flaps back to an account that just reset. `alert` remembers the
|
||||
# last thing said, so a shell restart or the next refresh doesn't repeat it.
|
||||
#
|
||||
# Numbers kept from an earlier check may be out of date, but a parked account's
|
||||
# sign-in lapses within hours and can't be checked again until a session runs
|
||||
# in it. So a stale account is still a candidate: among accounts with room, one
|
||||
# checked just now wins, but room comes first. An account within 15 points of
|
||||
# the threshold (where the panel starts checking more often) is near its limit
|
||||
# however fresh its numbers are.
|
||||
def decide(registry: dict[str, Any], usage: dict[str, Any], now: dt.datetime, stale: frozenset[str] = frozenset()) -> dict[str, Any] | None:
|
||||
threshold = float(registry["threshold"])
|
||||
current = active(registry)
|
||||
current_peak = peak(usage.get(current["id"]), now)
|
||||
if current_peak is None or current_peak < threshold:
|
||||
return {"action": "clear"} if registry.get("alert") else None
|
||||
|
||||
candidates = []
|
||||
for account in registry["accounts"]:
|
||||
if account["id"] == current["id"]:
|
||||
continue
|
||||
level = peak(usage.get(account["id"]), now)
|
||||
if level is None or level >= threshold:
|
||||
continue
|
||||
reset = next_reset(usage.get(account["id"]), now) or dt.datetime.max.replace(tzinfo=dt.timezone.utc)
|
||||
candidates.append((level >= threshold - 15, account["id"] in stale, level, reset, account))
|
||||
candidates.sort(key=lambda c: c[:4])
|
||||
|
||||
if candidates:
|
||||
_, _, level, _, target = candidates[0]
|
||||
action = "switch" if registry["switch"] == "auto" else "notify"
|
||||
alert = f"{action}:{current['id']}:{target['id']}"
|
||||
if registry.get("alert") == alert:
|
||||
return None
|
||||
return {"action": action, "alert": alert, "from": current, "to": target, "fromPeak": current_peak, "toPeak": level}
|
||||
|
||||
alert = f"exhausted:{current['id']}"
|
||||
if registry.get("alert") == alert or len(registry["accounts"]) < 2:
|
||||
return None
|
||||
# Only when every account is known to be over. One that's signed out or
|
||||
# couldn't be checked might have room, so there's nothing honest to say.
|
||||
if any(peak(usage.get(a["id"]), now) is None for a in registry["accounts"]):
|
||||
return None
|
||||
soonest = None
|
||||
for account in registry["accounts"]:
|
||||
reset = available_at(usage.get(account["id"]), threshold, now)
|
||||
if reset and (soonest is None or reset < soonest[0]):
|
||||
soonest = (reset, account)
|
||||
return {"action": "exhausted", "alert": alert, "from": current, "fromPeak": current_peak, "soonest": soonest}
|
||||
|
||||
|
||||
# Limits per account id. An account nobody is signed in to is left out, so it
|
||||
# is never a candidate: its cached headroom is no use to a session that can't
|
||||
# start. One whose token merely expired stays in, since a session refreshes it.
|
||||
def usage_by_account(provider: str) -> tuple[dict[str, Any], frozenset[str]]:
|
||||
record = read_json(usage_record_path(provider)) or {}
|
||||
accounts = [
|
||||
a for a in record.get("accounts") or []
|
||||
if isinstance(a, dict) and a.get("id") and a.get("usageStatusText") != "Waiting for auth"
|
||||
]
|
||||
return (
|
||||
{str(a["id"]): a.get("limits") for a in accounts},
|
||||
frozenset(str(a["id"]) for a in accounts if a.get("stale") is True),
|
||||
)
|
||||
|
||||
|
||||
def notify(title: str, body: str, action: list[str] | None = None) -> None:
|
||||
command = ["omarchy-notification-send", "--app-name", "Omarchy Agents", "-g", "", title, body]
|
||||
if action:
|
||||
command += ["--exec"] + action
|
||||
subprocess.run(command, check=False)
|
||||
|
||||
|
||||
# Returns True when the active account changed.
|
||||
def autoswitch(provider: str, now: dt.datetime | None = None) -> bool:
|
||||
now = now or dt.datetime.now(dt.timezone.utc)
|
||||
name = PROVIDERS[provider]["name"]
|
||||
with Locked(provider) as registry:
|
||||
if len(registry["accounts"]) < 2:
|
||||
return False
|
||||
usage, stale = usage_by_account(provider)
|
||||
decision = decide(registry, usage, now, stale)
|
||||
if decision is None:
|
||||
return False
|
||||
if decision["action"] == "clear":
|
||||
registry["alert"] = ""
|
||||
return False
|
||||
|
||||
registry["alert"] = decision["alert"]
|
||||
source = decision["from"]
|
||||
threshold = registry["threshold"]
|
||||
if decision["action"] == "exhausted":
|
||||
soonest = decision["soonest"]
|
||||
when = f" {soonest[1]['label']} resets in {duration(soonest[0] - now)}." if soonest else ""
|
||||
notify(f"All {name} accounts are over {threshold}%", f"Staying on {source['label']}.{when}")
|
||||
return False
|
||||
|
||||
target = decision["to"]
|
||||
headline = f"{source['label']} is at {round(decision['fromPeak'])}% of a {name} limit"
|
||||
if decision["action"] == "switch":
|
||||
registry["active"] = target["id"]
|
||||
if not target.get("primary"):
|
||||
link_shared(provider, account_home(provider, target))
|
||||
notify(headline, f"New {name} sessions now use {target['label']} ({round(decision['toPeak'])}%). Running sessions stay on {source['label']}.")
|
||||
return True
|
||||
notify(
|
||||
headline,
|
||||
f"{target['label']} is at {round(decision['toPeak'])}%. Click to switch new sessions to it.",
|
||||
["omarchy-agent-account-use", provider, target["id"]],
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- CLI
|
||||
|
||||
|
||||
def provider_arg(args: list[str]) -> str:
|
||||
if not args or args[0] not in PROVIDERS:
|
||||
raise AccountError("Name a provider: claude, codex, or grok")
|
||||
return args[0]
|
||||
|
||||
|
||||
def summary(provider: str, registry: dict[str, Any]) -> dict[str, Any]:
|
||||
# Identity is re-read from each home rather than trusted from the registry:
|
||||
# a `claude auth login` run by hand in a home changes who it is.
|
||||
for account in registry["accounts"]:
|
||||
account.update(identity(provider, account_home(provider, account)))
|
||||
return {
|
||||
"provider": provider,
|
||||
"name": PROVIDERS[provider]["name"],
|
||||
"active": registry["active"],
|
||||
"switch": registry["switch"],
|
||||
"threshold": registry["threshold"],
|
||||
"accounts": [
|
||||
{key: account.get(key, "") for key in ("id", "label", "email", "org", "plan", "primary")}
|
||||
| {
|
||||
"home": str(account_home(provider, account)),
|
||||
"active": account["id"] == registry["active"],
|
||||
"signedIn": bool(account.get("accountId")),
|
||||
}
|
||||
for account in registry["accounts"]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if not argv:
|
||||
print(__doc__.strip().splitlines()[0], file=sys.stderr)
|
||||
return 1
|
||||
command, args = argv[0], argv[1:]
|
||||
try:
|
||||
if command == "registry":
|
||||
provider = provider_arg(args)
|
||||
print(json.dumps(summary(provider, load(provider))))
|
||||
elif command == "list":
|
||||
providers = [provider_arg(args)] if args else list(PROVIDERS)
|
||||
print(json.dumps([summary(p, load(p)) for p in providers]))
|
||||
elif command == "home":
|
||||
# Empty for the primary, so launching it needs no environment at all.
|
||||
provider = provider_arg(args)
|
||||
registry = read_json(registry_path(provider))
|
||||
if registry:
|
||||
account = active(normalized(provider, registry))
|
||||
if not account.get("primary"):
|
||||
print(account_home(provider, account))
|
||||
elif command == "homes":
|
||||
provider = provider_arg(args)
|
||||
registry = load(provider)
|
||||
for account in registry["accounts"]:
|
||||
print("\t".join([account["id"], str(account_home(provider, account)), "1" if account["id"] == registry["active"] else "0"]))
|
||||
elif command == "refresh":
|
||||
for provider in ([provider_arg(args)] if args else list(PROVIDERS)):
|
||||
if registry_path(provider).exists():
|
||||
refresh(provider)
|
||||
elif command == "begin":
|
||||
print(begin(provider_arg(args)))
|
||||
elif command == "register":
|
||||
provider = provider_arg(args)
|
||||
if len(args) < 3:
|
||||
raise AccountError("register <provider> <label> <pending-home>")
|
||||
print(json.dumps(register(provider, args[1], Path(args[2]))))
|
||||
elif command == "use":
|
||||
provider = provider_arg(args)
|
||||
previous, account = use(provider, args[1] if len(args) > 1 else "next")
|
||||
print(json.dumps({"previous": previous, "active": account}))
|
||||
elif command == "remove":
|
||||
provider = provider_arg(args)
|
||||
if len(args) < 2:
|
||||
raise AccountError("remove <provider> <account>")
|
||||
print(json.dumps(remove(provider, args[1])))
|
||||
elif command == "rename":
|
||||
provider = provider_arg(args)
|
||||
if len(args) < 3:
|
||||
raise AccountError("rename <provider> <account> <label>")
|
||||
print(json.dumps(rename(provider, args[1], " ".join(args[2:]))))
|
||||
elif command == "mode":
|
||||
provider = provider_arg(args)
|
||||
if len(args) < 2:
|
||||
raise AccountError("mode <provider> <manual|auto> [threshold]")
|
||||
set_mode(provider, args[1], args[2] if len(args) > 2 else None)
|
||||
elif command == "link":
|
||||
provider = provider_arg(args)
|
||||
link_shared(provider, Path(args[1]))
|
||||
elif command == "autoswitch":
|
||||
# Prints each provider whose active account changed, so the caller
|
||||
# knows which usage records to collect again.
|
||||
for provider in ([provider_arg(args)] if args else list(PROVIDERS)):
|
||||
if registry_path(provider).exists() and autoswitch(provider):
|
||||
print(provider)
|
||||
else:
|
||||
raise AccountError(f"Unknown command: {command}")
|
||||
except AccountError as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Choose which Claude, Codex, or Grok account new sessions use
|
||||
# omarchy:args=[claude|codex|grok] [account|next]
|
||||
# omarchy:examples=omarchy agent account use work | omarchy agent account use main | omarchy agent account use codex next
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# The provider can be left out when it's your default agent:
|
||||
# `omarchy agent account use work`.
|
||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
|
||||
default=$(omarchy-default-agent)
|
||||
[[ $default == "claude" || $default == "codex" || $default == "grok" ]] && set -- "$default" "$@"
|
||||
fi
|
||||
|
||||
case "${1:-}" in
|
||||
claude) name="Claude" ;;
|
||||
codex) name="Codex" ;;
|
||||
grok) name="Grok" ;;
|
||||
*)
|
||||
echo "Usage: omarchy-agent-account-use [claude|codex|grok] [account|next]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
result=$(omarchy-agent-account-state use "$1" "${2:-next}") || exit 1
|
||||
|
||||
previous=$(jq -r .previous.label <<<"$result")
|
||||
active=$(jq -r '.active.label + (if (.active.plan // "") != "" then " (" + .active.plan + ")" else "" end)' <<<"$result")
|
||||
|
||||
if [[ $(jq -r .previous.id <<<"$result") == "$(jq -r .active.id <<<"$result")" ]]; then
|
||||
echo "New $name sessions already use $active."
|
||||
else
|
||||
message="Running sessions stay on $previous."
|
||||
echo "New $name sessions now use $active. $message"
|
||||
omarchy-notification-send -g "New $name sessions now use $active" "$message"
|
||||
# The usage record's own limits describe the active account.
|
||||
omarchy-agent-usage-update --limits-only "$1" >/dev/null 2>&1 &
|
||||
fi
|
||||
+106
-487
@@ -35,10 +35,6 @@ AGENT_NAME = "Claude Code"
|
||||
AUTH_HELP = "Run `claude auth login` to restore authoritative usage."
|
||||
USAGE_ENDPOINT = "https://api.anthropic.com/api/oauth/usage"
|
||||
PROBE_MIN_INTERVAL_SECONDS = 15
|
||||
# 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:
|
||||
@@ -122,209 +118,12 @@ def empty_bucket() -> dict[str, int]:
|
||||
# ---------------------------------------------------------------- local scan
|
||||
|
||||
|
||||
# Transcripts are append-only, and most of the corpus never changes between
|
||||
# ticks, so the scan keeps a per-file index: the unique usage records already
|
||||
# parsed out of each file plus the byte offset they end at. A file whose size
|
||||
# and mtime match the index is not opened; a file that grew is read from the
|
||||
# stored offset; anything else (truncated, rewritten, replaced, unknown) is
|
||||
# read from the start. Without this the whole history was re-parsed every
|
||||
# refresh, and the cost grew for the life of the machine.
|
||||
|
||||
INDEX_VERSION = 4
|
||||
|
||||
# How much of each end of what was read the index remembers a hash of, so a
|
||||
# file replaced or rewritten with more than it had is not mistaken for an
|
||||
# append.
|
||||
INDEX_HEAD_BYTES = 4096
|
||||
|
||||
|
||||
# Records hold local days, so an index is only true in the timezone that
|
||||
# wrote it; after a change every transcript is read again.
|
||||
def index_zone() -> list[Any]:
|
||||
return [os.environ.get("TZ"), list(time.tzname), time.timezone, time.altzone]
|
||||
|
||||
|
||||
def load_index(index_file: Path) -> dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(index_file.read_text(encoding="utf-8"))
|
||||
if (
|
||||
isinstance(data, dict)
|
||||
and data.get("version") == INDEX_VERSION
|
||||
and data.get("zone") == index_zone()
|
||||
and isinstance(data.get("files"), dict)
|
||||
):
|
||||
return data["files"]
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
# What was already read, by its first and last few kilobytes: a transcript
|
||||
# rewritten in place rather than appended to changes one or the other.
|
||||
def read_digest(path: Path, length: int) -> str:
|
||||
digest = hashlib.sha1()
|
||||
with path.open("rb") as handle:
|
||||
digest.update(handle.read(min(length, INDEX_HEAD_BYTES)))
|
||||
handle.seek(max(0, length - INDEX_HEAD_BYTES))
|
||||
digest.update(handle.read(min(length, INDEX_HEAD_BYTES)))
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def parse_usage_records(path: Path, entry: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Return the index entry for one transcript, reusing `entry` where the file
|
||||
only grew. Records are [key, model, day, input, output, cacheRead,
|
||||
cacheWrite, session]; `day` is None when the line carries no timestamp."""
|
||||
stat = path.stat()
|
||||
size = stat.st_size
|
||||
mtime = stat.st_mtime_ns
|
||||
|
||||
if entry and entry.get("size") == size and entry.get("mtime") == mtime:
|
||||
return entry
|
||||
|
||||
# Only the same file, grown past what was read with what was read still in
|
||||
# place, is an append. A transcript replaced by a larger one is a new file,
|
||||
# and one rewritten in place has a different start or end.
|
||||
if (
|
||||
entry
|
||||
and size > number(entry.get("size"))
|
||||
and entry.get("file") == [stat.st_dev, stat.st_ino]
|
||||
and entry.get("head") == read_digest(path, number(entry.get("offset")))
|
||||
):
|
||||
records = list(entry.get("records") or [])
|
||||
offset = number(entry.get("offset"))
|
||||
line_number = number(entry.get("lines"))
|
||||
else:
|
||||
records = []
|
||||
offset = 0
|
||||
line_number = 0
|
||||
|
||||
# A streamed response is several lines sharing one message id, and the
|
||||
# first line's output_tokens is a placeholder while the last carries the
|
||||
# real count. Keep each message's line with the highest output count (the
|
||||
# last one read wins a tie), whole: a response that fell back to another
|
||||
# model mid-stream has lines that are separate snapshots, and mixing fields
|
||||
# across them over-counts cache tokens and credits the wrong model.
|
||||
positions = {record[0]: index for index, record in enumerate(records)}
|
||||
with path.open("rb") as handle:
|
||||
handle.seek(offset)
|
||||
for raw in handle:
|
||||
if not raw.endswith(b"\n"):
|
||||
# A line still being written; pick it up whole next time.
|
||||
break
|
||||
offset += len(raw)
|
||||
line_number += 1
|
||||
|
||||
# Cheap pre-filter before JSON parsing keeps files with unrelated
|
||||
# lines inexpensive.
|
||||
if b'"usage":' not in raw:
|
||||
continue
|
||||
|
||||
try:
|
||||
parsed = json.loads(raw.decode("utf-8", errors="replace"))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
message = parsed.get("message") if isinstance(parsed.get("message"), dict) else {}
|
||||
if parsed.get("type") != "assistant" and message.get("role") != "assistant":
|
||||
continue
|
||||
|
||||
usage = message.get("usage") or parsed.get("usage")
|
||||
if not isinstance(usage, dict):
|
||||
continue
|
||||
|
||||
message_id = message.get("id") or parsed.get("messageId") or ""
|
||||
key = str(message_id) if message_id else f"{path}:{parsed.get('uuid') or parsed.get('requestId') or line_number}"
|
||||
|
||||
input_tokens = usage_token(usage, "input_tokens", "inputTokens")
|
||||
output_tokens = usage_token(usage, "output_tokens", "outputTokens")
|
||||
cache_read = usage_token(usage, "cache_read_input_tokens", "cacheReadInputTokens")
|
||||
cache_write = usage_token(usage, "cache_creation_input_tokens", "cacheCreationInputTokens")
|
||||
if input_tokens + output_tokens + cache_read + cache_write <= 0:
|
||||
continue
|
||||
|
||||
held = positions.get(key)
|
||||
if held is not None and output_tokens < records[held][4]:
|
||||
continue
|
||||
|
||||
timestamp = parsed.get("timestamp") or message.get("timestamp")
|
||||
record = [
|
||||
key,
|
||||
str(message.get("model") or parsed.get("model") or "claude"),
|
||||
local_date_from_timestamp(timestamp) if timestamp is not None else None,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cache_read,
|
||||
cache_write,
|
||||
str(parsed.get("sessionId") or path),
|
||||
]
|
||||
if held is None:
|
||||
positions[key] = len(records)
|
||||
records.append(record)
|
||||
else:
|
||||
records[held] = record
|
||||
|
||||
return {
|
||||
"size": size,
|
||||
"file": [stat.st_dev, stat.st_ino],
|
||||
"mtime": mtime,
|
||||
"head": read_digest(path, offset),
|
||||
"offset": offset,
|
||||
"lines": line_number,
|
||||
"records": records,
|
||||
}
|
||||
|
||||
|
||||
def scan_projects(projects_path: Path, index_file: Path | None = None) -> dict[str, Any]:
|
||||
previous = load_index(index_file) if index_file else {}
|
||||
current: dict[str, Any] = {}
|
||||
|
||||
# Directory order, as before: when one API message shows up in several
|
||||
# transcripts with the same output count, the last file visited wins, and
|
||||
# the numbers must not move because the scan got faster.
|
||||
order: list[str] = []
|
||||
unreadable_count = 0
|
||||
unreadable_examples: list[str] = []
|
||||
files = projects_path.rglob("*.jsonl") if projects_path.is_dir() else []
|
||||
for path in files:
|
||||
key = str(path)
|
||||
try:
|
||||
current[key] = parse_usage_records(path, previous.get(key))
|
||||
except Exception as exc:
|
||||
unreadable_count += 1
|
||||
if len(unreadable_examples) < 3:
|
||||
unreadable_examples.append(f"{path}: {exc}")
|
||||
# What was read before is still true of the file.
|
||||
if key in previous:
|
||||
current[key] = previous[key]
|
||||
if key in current:
|
||||
order.append(key)
|
||||
|
||||
if unreadable_count:
|
||||
examples = "; ".join(unreadable_examples)
|
||||
print(
|
||||
f"Ignoring {unreadable_count} unreadable Claude project files"
|
||||
f" (examples: {examples})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
if index_file and current != previous:
|
||||
try:
|
||||
write_json(index_file, {"version": INDEX_VERSION, "zone": index_zone(), "files": current})
|
||||
except Exception as exc:
|
||||
print(f"Could not write Claude scan index {index_file}: {exc}", file=sys.stderr)
|
||||
|
||||
return summarize_records(current, order)
|
||||
|
||||
|
||||
def summarize_records(indexed: dict[str, Any], order: list[str]) -> dict[str, Any]:
|
||||
def scan_projects(projects_path: Path) -> dict[str, Any]:
|
||||
today = local_date_string()
|
||||
recent_dates = recent_date_strings()
|
||||
recent = {day: {"date": day, "messageCount": 0} for day in recent_dates}
|
||||
|
||||
# One API message can show up in several transcripts (a resumed session
|
||||
# carries the earlier ones). As within a file, the record with the highest
|
||||
# output count wins, the last one visited breaking a tie.
|
||||
seen: dict[str, list[Any]] = {}
|
||||
seen: set[str] = set()
|
||||
sessions: set[str] = set()
|
||||
active_days: set[str] = set()
|
||||
today_sessions: set[str] = set()
|
||||
@@ -334,38 +133,68 @@ def summarize_records(indexed: dict[str, Any], order: list[str]) -> dict[str, An
|
||||
today_prompt_count = 0
|
||||
today_token_total = 0
|
||||
|
||||
for key in order:
|
||||
for record in indexed[key].get("records") or []:
|
||||
held = seen.get(record[0])
|
||||
if held is not None and record[4] < held[4]:
|
||||
continue
|
||||
seen[record[0]] = record
|
||||
files = projects_path.rglob("*.jsonl") if projects_path.is_dir() else []
|
||||
for path in files:
|
||||
try:
|
||||
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
||||
for line_number, line in enumerate(handle, 1):
|
||||
# Cheap pre-filter before JSON parsing keeps files with unrelated
|
||||
# lines inexpensive.
|
||||
if '"usage":' not in line:
|
||||
continue
|
||||
|
||||
for _, model, day, input_tokens, output_tokens, cache_read, cache_write, session_key in seen.values():
|
||||
if day is None:
|
||||
day = today
|
||||
total = input_tokens + output_tokens + cache_read + cache_write
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
sessions.add(session_key)
|
||||
active_days.add(day)
|
||||
prompts += 1
|
||||
message = entry.get("message") if isinstance(entry.get("message"), dict) else {}
|
||||
if entry.get("type") != "assistant" and message.get("role") != "assistant":
|
||||
continue
|
||||
|
||||
bucket = usage_by_model.setdefault(model, empty_bucket())
|
||||
bucket["inputTokens"] += input_tokens
|
||||
bucket["outputTokens"] += output_tokens
|
||||
bucket["cacheReadInputTokens"] += cache_read
|
||||
bucket["cacheCreationInputTokens"] += cache_write
|
||||
usage = message.get("usage") or entry.get("usage")
|
||||
if not isinstance(usage, dict):
|
||||
continue
|
||||
|
||||
if day in recent:
|
||||
# recentDays.messageCount is actually a token total, despite the
|
||||
# legacy name shared with synced snapshots.
|
||||
recent[day]["messageCount"] += total
|
||||
message_id = message.get("id") or entry.get("messageId") or ""
|
||||
unique_key = str(message_id) if message_id else f"{path}:{entry.get('uuid') or entry.get('requestId') or line_number}"
|
||||
if unique_key in seen:
|
||||
continue
|
||||
seen.add(unique_key)
|
||||
|
||||
if day == today:
|
||||
today_prompt_count += 1
|
||||
today_sessions.add(session_key)
|
||||
today_token_total += total
|
||||
today_tokens[model] = today_tokens.get(model, 0) + total
|
||||
input_tokens = usage_token(usage, "input_tokens", "inputTokens")
|
||||
output_tokens = usage_token(usage, "output_tokens", "outputTokens")
|
||||
cache_read = usage_token(usage, "cache_read_input_tokens", "cacheReadInputTokens")
|
||||
cache_write = usage_token(usage, "cache_creation_input_tokens", "cacheCreationInputTokens")
|
||||
total = input_tokens + output_tokens + cache_read + cache_write
|
||||
if total <= 0:
|
||||
continue
|
||||
|
||||
model = str(message.get("model") or entry.get("model") or "claude")
|
||||
day = local_date_from_timestamp(entry.get("timestamp") or message.get("timestamp"))
|
||||
session_key = str(entry.get("sessionId") or path)
|
||||
sessions.add(session_key)
|
||||
active_days.add(day)
|
||||
prompts += 1
|
||||
|
||||
bucket = usage_by_model.setdefault(model, empty_bucket())
|
||||
bucket["inputTokens"] += input_tokens
|
||||
bucket["outputTokens"] += output_tokens
|
||||
bucket["cacheReadInputTokens"] += cache_read
|
||||
bucket["cacheCreationInputTokens"] += cache_write
|
||||
|
||||
if day in recent:
|
||||
# recentDays.messageCount is actually a token total, despite the
|
||||
# legacy name shared with synced snapshots.
|
||||
recent[day]["messageCount"] += total
|
||||
|
||||
if day == today:
|
||||
today_prompt_count += 1
|
||||
today_sessions.add(session_key)
|
||||
today_token_total += total
|
||||
today_tokens[model] = today_tokens.get(model, 0) + total
|
||||
except Exception as exc:
|
||||
print(f"Ignoring unreadable Claude project file {path}: {exc}", file=sys.stderr)
|
||||
|
||||
return {
|
||||
"todayPrompts": today_prompt_count,
|
||||
@@ -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]
|
||||
root = cache_root()
|
||||
return root / f"claude-scan-{digest}.json", root / f"claude-scan-{digest}.lock", root / f"claude-index-{digest}.json"
|
||||
|
||||
|
||||
def read_json(path: Path) -> dict[str, Any] | None:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
return root / f"claude-scan-{digest}.json", root / f"claude-scan-{digest}.lock"
|
||||
|
||||
|
||||
def read_fresh_json(path: Path, max_age_seconds: float) -> dict[str, Any] | None:
|
||||
if max_age_seconds <= 0 or not path.exists():
|
||||
return None
|
||||
try:
|
||||
# A negative age means the mtime is in the future: the clock moved
|
||||
# backwards since the write, so the cache's freshness cannot be trusted.
|
||||
age = time.time() - path.stat().st_mtime
|
||||
if 0 <= age <= max_age_seconds:
|
||||
return read_json(path)
|
||||
if time.time() - path.stat().st_mtime <= max_age_seconds:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
@@ -429,8 +248,8 @@ def write_json(path: Path, payload: dict[str, Any]) -> None:
|
||||
raise
|
||||
|
||||
|
||||
def cached_scan(projects_path: Path, max_age_seconds: float, use_index: bool = True) -> dict[str, Any]:
|
||||
cache_file, lock_file, index_file = scan_cache_paths(projects_path)
|
||||
def cached_scan(projects_path: Path, max_age_seconds: float) -> dict[str, Any]:
|
||||
cache_file, lock_file = scan_cache_paths(projects_path)
|
||||
|
||||
cached = read_fresh_json(cache_file, max_age_seconds)
|
||||
if cached is not None:
|
||||
@@ -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)
|
||||
if cached is not None:
|
||||
return cached
|
||||
if not use_index:
|
||||
index_file.unlink(missing_ok=True)
|
||||
summary = scan_projects(projects_path, index_file)
|
||||
summary = scan_projects(projects_path)
|
||||
write_json(cache_file, summary)
|
||||
return summary
|
||||
|
||||
@@ -520,27 +337,11 @@ def today_prompts_from_history(claude_dir: Path) -> tuple[int, int]:
|
||||
# provider, model, and token usage on every assistant message.
|
||||
|
||||
|
||||
# `omp --profile=<name>` (and pi's equivalent) relocates the whole agent tree
|
||||
# under <base>/profiles/<name>/, so a subscription driven entirely through a
|
||||
# profile leaves the default root empty and its usage uncounted. Sessions are
|
||||
# keyed by file path, so a profile adds sessions rather than double-counting
|
||||
# the default root.
|
||||
def pi_session_roots() -> list[Path]:
|
||||
roots: list[Path] = []
|
||||
for base in (Path.home() / ".pi", Path.home() / ".omp"):
|
||||
roots.append(base / "agent" / "sessions")
|
||||
profiles = base / "profiles"
|
||||
try:
|
||||
# Sorted so the scan order does not depend on directory order.
|
||||
roots.extend(sorted(child / "agent" / "sessions" for child in profiles.iterdir() if child.is_dir()))
|
||||
except OSError:
|
||||
# No profiles directory, or it is unreadable: the default root stands.
|
||||
pass
|
||||
return roots
|
||||
|
||||
|
||||
def scan_pi_usage(max_age_seconds: float) -> dict[str, Any] | None:
|
||||
roots = pi_session_roots()
|
||||
roots = [
|
||||
Path.home() / ".pi" / "agent" / "sessions",
|
||||
Path.home() / ".omp" / "agent" / "sessions",
|
||||
]
|
||||
cache_file = cache_root() / "claude-pi-sessions.json"
|
||||
cached = read_fresh_json(cache_file, max_age_seconds)
|
||||
if cached is not None:
|
||||
@@ -672,36 +473,16 @@ def scan_opencode_usage(max_age_seconds: float) -> dict[str, Any] | None:
|
||||
return None
|
||||
try:
|
||||
conn.execute("PRAGMA query_only = ON")
|
||||
tables = {name for (name,) in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")}
|
||||
# opencode v1 keeps messages in `message`, with the role, provider and model
|
||||
# in each row's data. v2 writes `session_message` instead: the role moves to
|
||||
# the `type` column and the provider and model nest under data.model. A
|
||||
# database can hold either or both.
|
||||
queries = []
|
||||
if "message" in tables:
|
||||
queries.append(("opencode:", "SELECT session_id, data, NULL FROM message"))
|
||||
if "session_message" in tables:
|
||||
queries.append(("opencode-v2:", "SELECT session_id, data, type FROM session_message"))
|
||||
rows = ((prefix, row) for prefix, query in queries for row in conn.execute(query))
|
||||
for session_prefix, (session_id, raw, row_type) in rows:
|
||||
for session_id, raw in conn.execute("SELECT session_id, data FROM message"):
|
||||
# One malformed row must not abort the scan, so every shape assumption
|
||||
# lives inside the try.
|
||||
try:
|
||||
entry = json.loads(raw)
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if row_type is None:
|
||||
role = entry.get("role")
|
||||
provider = entry.get("providerID")
|
||||
model_id = entry.get("modelID")
|
||||
else:
|
||||
role = row_type
|
||||
model_info = entry.get("model") or {}
|
||||
provider = model_info.get("providerID")
|
||||
model_id = model_info.get("id")
|
||||
# Exact match: opencode provider ids are free-form, and a custom
|
||||
# "anthropic-proxy" gateway is not this subscription.
|
||||
if role != "assistant" or str(provider or "") != "anthropic":
|
||||
if not isinstance(entry, dict) or entry.get("role") != "assistant":
|
||||
continue
|
||||
if str(entry.get("providerID") or "") != "anthropic":
|
||||
continue
|
||||
tokens = entry.get("tokens") or {}
|
||||
cache = tokens.get("cache") or {}
|
||||
@@ -716,10 +497,10 @@ def scan_opencode_usage(max_age_seconds: float) -> dict[str, Any] | None:
|
||||
|
||||
created = number((entry.get("time") or {}).get("created"))
|
||||
day = dt.datetime.fromtimestamp(created / 1000).strftime("%Y-%m-%d") if created > 0 else today
|
||||
model = str(model_id or "claude").rstrip("/").split("/")[-1]
|
||||
model = str(entry.get("modelID") or "claude").rstrip("/").split("/")[-1]
|
||||
except Exception:
|
||||
continue
|
||||
session_key = session_prefix + str(session_id)
|
||||
session_key = "opencode:" + str(session_id)
|
||||
sessions.add(session_key)
|
||||
active_days.add(day)
|
||||
prompts += 1
|
||||
@@ -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
|
||||
# nowhere but the Authorization header of the limits probe, and only the
|
||||
# plan label may travel into the printed record.
|
||||
def oauth_login(claude_dir: Path) -> tuple[str, int, int, str]:
|
||||
def oauth_login(claude_dir: Path) -> tuple[str, int, str]:
|
||||
try:
|
||||
data = json.loads((claude_dir / ".credentials.json").read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return "", 0, 0, ""
|
||||
return "", 0, ""
|
||||
login = data.get("claudeAiOauth")
|
||||
if not isinstance(login, dict):
|
||||
return "", 0, 0, ""
|
||||
# The profile's tier is asked first, but only one that names a multiplier
|
||||
# outranks the credentials'. The subscription still leads the label, so a
|
||||
# Team seat keeps reading Team.
|
||||
tier = profile_tier(claude_dir)
|
||||
if not re.search(r"max_(\d+x)", tier, re.IGNORECASE):
|
||||
tier = str(login.get("rateLimitTier") or "")
|
||||
plan = plan_label(tier, str(login.get("subscriptionType") or ""))
|
||||
return (
|
||||
str(login.get("accessToken") or ""),
|
||||
number(login.get("expiresAt")),
|
||||
number(login.get("refreshTokenExpiresAt")),
|
||||
plan,
|
||||
)
|
||||
return "", 0, ""
|
||||
plan = plan_label(str(login.get("rateLimitTier") or ""), str(login.get("subscriptionType") or ""))
|
||||
return str(login.get("accessToken") or ""), number(login.get("expiresAt")), plan
|
||||
|
||||
|
||||
# The credentials carry the tier their token was minted with, and an upgrade
|
||||
# does not restamp it: a refresh rewrites the file and keeps the old value, so
|
||||
# an account moved to Max 20x keeps reading Max 5x. The CLI's own profile is
|
||||
# refreshed against the account, so the tier it states there is asked first.
|
||||
def profile_tier(claude_dir: Path) -> str:
|
||||
try:
|
||||
account = json.loads(profile_path(claude_dir).read_text(encoding="utf-8")).get("oauthAccount")
|
||||
except Exception:
|
||||
return ""
|
||||
if not isinstance(account, dict):
|
||||
return ""
|
||||
# A seat with no tier of its own is limited by its organization, which is
|
||||
# the shape a personal Max subscription takes.
|
||||
for key in ("userRateLimitTier", "organizationRateLimitTier"):
|
||||
tier = str(account.get(key) or "")
|
||||
if tier:
|
||||
return tier
|
||||
return ""
|
||||
|
||||
|
||||
# The CLI's profile for a config home: a legacy .config.json inside it, else
|
||||
# ~/.claude.json for the default home and <home>/.claude.json for one it was
|
||||
# pointed at with CLAUDE_CONFIG_DIR. A copy anywhere else is never refreshed,
|
||||
# and another home's profile describes another account.
|
||||
def profile_path(home: Path) -> Path:
|
||||
legacy = home / ".config.json"
|
||||
if legacy.exists():
|
||||
return legacy
|
||||
configured = os.environ.get("CLAUDE_CONFIG_DIR")
|
||||
if home == expand_path("~/.claude") and not (configured and expand_path(configured) == home):
|
||||
return Path.home() / ".claude.json"
|
||||
return home / ".claude.json"
|
||||
|
||||
|
||||
# The rate-limit tier names the ceiling, not the plan: a Team premium seat
|
||||
# runs on `default_claude_max_5x` while paying for Team. Lead with the
|
||||
# subscription and keep the multiplier as its qualifier, so Max still reads
|
||||
# "Max 5x" and a Team seat reads "Team 5x" rather than passing as Max.
|
||||
def plan_label(tier: str, subscription: str) -> str:
|
||||
plan = subscription[0].upper() + subscription[1:] if subscription else ""
|
||||
match = re.search(r"max_(\d+x)", tier, re.IGNORECASE) if tier else None
|
||||
if not match:
|
||||
return plan
|
||||
return (plan or "Max") + " " + match.group(1)
|
||||
if tier:
|
||||
match = re.search(r"max_(\d+x)", tier, re.IGNORECASE)
|
||||
if match:
|
||||
return "Max " + match.group(1)
|
||||
if subscription:
|
||||
return subscription[0].upper() + subscription[1:]
|
||||
return ""
|
||||
|
||||
|
||||
def parse_utilization(value: Any) -> float:
|
||||
@@ -1031,8 +765,11 @@ def probe_limits(access_token: str) -> dict[str, Any]:
|
||||
return {"ok": True, "limits": limits}
|
||||
|
||||
|
||||
# Cached windows reset to zero when their measured period ends. Without a
|
||||
# fresh probe, the next rolling window has no known reset time yet.
|
||||
# A cached percentage outlives the probe that measured it, but only until its
|
||||
# window rolls over: once a window has reset, the figure describes a period
|
||||
# that is over, and a stale 78% would misreport an allowance that is now
|
||||
# untouched. A window with no reset time, or one that will not parse, is kept
|
||||
# — an unreadable timestamp is no reason to throw away a real number.
|
||||
def limit_window_open(entry: dict[str, Any], now: dt.datetime) -> bool:
|
||||
raw = str(entry.get("resetsAt") or "")
|
||||
if raw == "":
|
||||
@@ -1051,54 +788,30 @@ def usable_cached_limits(cached: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
if not isinstance(entries, list):
|
||||
return []
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
return [
|
||||
entry if limit_window_open(entry, now) else dict(entry, percent=0.0, resetsAt="")
|
||||
for entry in entries if isinstance(entry, dict)
|
||||
]
|
||||
return [entry for entry in entries if isinstance(entry, dict) and limit_window_open(entry, now)]
|
||||
|
||||
|
||||
def collect_limits(access_token: str, expires_at_ms: int, refresh_expires_at_ms: int, force: bool, cache_name: str = "claude-limits.json") -> dict[str, Any]:
|
||||
# `live` says the numbers came from Anthropic just now (or within the reuse
|
||||
# window) rather than from a cache kept past a failed or impossible probe.
|
||||
result = {"limits": [], "usageStatusText": "", "authHelpText": AUTH_HELP, "live": False}
|
||||
|
||||
# When the numbers were measured, so a stale account can say how old
|
||||
# they are.
|
||||
result["fetchedAtMs"] = 0
|
||||
def collect_limits(access_token: str, expires_at_ms: int, force: bool) -> dict[str, Any]:
|
||||
result = {"limits": [], "usageStatusText": "", "authHelpText": AUTH_HELP}
|
||||
|
||||
# A panel that is opened and shut repeatedly must not turn into a request
|
||||
# per flick, so recent probe results are reused for a short window — and
|
||||
# kept as the answer of record when a later probe fails. Every account
|
||||
# keeps its own, so one account's allowance never stands in for another's.
|
||||
probe_cache = cache_root() / cache_name
|
||||
cached = read_json(probe_cache) or {}
|
||||
# kept as the answer of record when a later probe fails.
|
||||
probe_cache = cache_root() / "claude-limits.json"
|
||||
cached = read_fresh_json(probe_cache, float("inf")) or {}
|
||||
fallback = usable_cached_limits(cached)
|
||||
result["fetchedAtMs"] = number(cached.get("fetchedAtMs"))
|
||||
|
||||
# Probing needs a live token and only the Claude Code CLI can mint one: it
|
||||
# refreshes the credential file when it runs, so a machine left alone long
|
||||
# enough finds the saved token lapsed. Say so — an empty limits list with
|
||||
# nothing else set hides the whole section and explains nothing — and keep
|
||||
# showing the last numbers, resetting elapsed windows to zero.
|
||||
# showing the last numbers whose window has not since reset.
|
||||
if access_token == "":
|
||||
result["limits"] = fallback
|
||||
result["usageStatusText"] = "Waiting for auth"
|
||||
return result
|
||||
if expires_at_ms > 0 and expires_at_ms <= time.time() * 1000:
|
||||
result["limits"] = fallback
|
||||
# A lapsed access token is routine, not a sign-out: it lives about eight
|
||||
# hours, and the CLI mints a new one from the refresh token the next time
|
||||
# it runs. Only a lapsed *refresh* token is a sign-in a person must redo,
|
||||
# and saying so either way sends people to `claude auth login` nightly --
|
||||
# which does not restore anything the CLI would not have restored itself.
|
||||
if refresh_expires_at_ms > time.time() * 1000:
|
||||
result["usageStatusText"] = "Limits paused"
|
||||
result["authHelpText"] = (
|
||||
"Claude Code's access token has lapsed"
|
||||
+ (" — showing the last known limits." if fallback else ".")
|
||||
+ " Start Claude Code to refresh it; signing in again is not needed."
|
||||
)
|
||||
return result
|
||||
result["usageStatusText"] = "Sign-in expired"
|
||||
result["authHelpText"] = (
|
||||
"Claude Code's saved sign-in expired"
|
||||
@@ -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
|
||||
# overrule someone who pressed refresh.
|
||||
fetched_at = number(cached.get("fetchedAtMs")) / 1000
|
||||
fetched_age = time.time() - fetched_at
|
||||
if fallback and not force and 0 <= fetched_age < PROBE_MIN_INTERVAL_SECONDS:
|
||||
if fallback and not force and time.time() - fetched_at < PROBE_MIN_INTERVAL_SECONDS:
|
||||
result["limits"] = fallback
|
||||
result["live"] = True
|
||||
result["authHelpText"] = ""
|
||||
return result
|
||||
|
||||
probe = probe_limits(access_token)
|
||||
if probe["ok"]:
|
||||
result["limits"] = probe["limits"]
|
||||
result["live"] = True
|
||||
result["authHelpText"] = ""
|
||||
result["fetchedAtMs"] = round(time.time() * 1000)
|
||||
write_json(probe_cache, {"fetchedAtMs": result["fetchedAtMs"], "limits": probe["limits"]})
|
||||
write_json(probe_cache, {"fetchedAtMs": round(time.time() * 1000), "limits": probe["limits"]})
|
||||
return result
|
||||
|
||||
# The first probe after login often fires before DHCP has handed out a
|
||||
@@ -1133,95 +840,25 @@ def collect_limits(access_token: str, expires_at_ms: int, refresh_expires_at_ms:
|
||||
result["retryAdvised"] = True
|
||||
if fallback:
|
||||
result["limits"] = fallback
|
||||
fetched_age = time.time() - result["fetchedAtMs"] / 1000
|
||||
result["live"] = 0 <= fetched_age < CURRENT_ENOUGH_SECONDS
|
||||
else:
|
||||
result["usageStatusText"] = "Claude limits unavailable"
|
||||
result["authHelpText"] = probe["helpText"]
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- accounts
|
||||
|
||||
|
||||
# The accounts `omarchy agent account` registered, read straight from its
|
||||
# registry. Only a registry holding a second account matters: with one, the
|
||||
# record is exactly what it always was.
|
||||
def registered_accounts() -> list[dict[str, Any]]:
|
||||
state = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state")
|
||||
try:
|
||||
registry = json.loads((state / "omarchy" / "agents" / "accounts" / "claude.json").read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
accounts = [a for a in registry.get("accounts") or [] if isinstance(a, dict) and a.get("id")]
|
||||
if len(accounts) < 2:
|
||||
return []
|
||||
active_id = registry.get("active") or accounts[0]["id"]
|
||||
if not any(a["id"] == active_id for a in accounts):
|
||||
active_id = accounts[0]["id"]
|
||||
for account in accounts:
|
||||
account["active"] = account["id"] == active_id
|
||||
account["switch"] = {
|
||||
"mode": "auto" if registry.get("switch") == "auto" else "manual",
|
||||
"threshold": registry.get("threshold") or 95,
|
||||
}
|
||||
return accounts
|
||||
|
||||
|
||||
def current_account_id(home: Path) -> str:
|
||||
try:
|
||||
account = json.loads(profile_path(home).read_text(encoding="utf-8")).get("oauthAccount") or {}
|
||||
except Exception:
|
||||
return ""
|
||||
return str(account.get("accountUuid") or "")
|
||||
|
||||
|
||||
def account_limits(account: dict[str, Any], force: bool) -> dict[str, Any]:
|
||||
home = expand_path(account["home"]) if account.get("home") else expand_path("~/.claude")
|
||||
access_token, expires_at_ms, refresh_expires_at_ms, plan = oauth_login(home)
|
||||
# Keyed by the subscription, not the label: forget "Work" and add a different
|
||||
# "Work", and the new one must never fall back on the old one's allowance.
|
||||
# Who the home is signed in as now, not when it was added: a `claude auth
|
||||
# login` run in it by hand makes it a different subscription.
|
||||
identity = current_account_id(home)
|
||||
key = re.sub(r"[^A-Za-z0-9_-]", "", identity or str(account.get("accountId") or "")) or account["id"]
|
||||
# The primary home is keyed the same way once it says who it's signed in
|
||||
# as, so signing it in to another subscription never inherits the last
|
||||
# one's numbers; until then it keeps the single-account cache.
|
||||
if account.get("primary") and not identity:
|
||||
cache_name = "claude-limits.json"
|
||||
else:
|
||||
cache_name = f"claude-limits-{key}.json"
|
||||
limits = collect_limits(access_token, expires_at_ms, refresh_expires_at_ms, force, cache_name)
|
||||
return {
|
||||
"id": account["id"],
|
||||
"label": str(account.get("label") or account["id"]),
|
||||
"email": str(account.get("email") or ""),
|
||||
"plan": plan or str(account.get("plan") or ""),
|
||||
"active": account["active"],
|
||||
"primary": bool(account.get("primary")),
|
||||
"limits": limits["limits"],
|
||||
"stale": not limits["live"],
|
||||
"fetchedAt": limits["fetchedAtMs"],
|
||||
"usageStatusText": limits["usageStatusText"],
|
||||
"authHelpText": limits["authHelpText"],
|
||||
"retryAdvised": bool(limits.get("retryAdvised")),
|
||||
}
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- record
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--force", action="store_true", help="rescan transcripts from scratch and re-probe limits, ignoring caches")
|
||||
parser.add_argument("--force", action="store_true", help="rescan transcripts and re-probe limits, ignoring caches")
|
||||
parser.add_argument("--limits-only", action="store_true", help="reuse any recent transcript scan; only the limits probe must be fresh")
|
||||
parser.add_argument("--cache-seconds", type=float, default=20)
|
||||
args = parser.parse_args()
|
||||
|
||||
claude_dir = config_dir()
|
||||
scan_age = 0 if args.force else (900 if args.limits_only else args.cache_seconds)
|
||||
stats = cached_scan(claude_dir / "projects", scan_age, use_index=not args.force)
|
||||
stats = cached_scan(claude_dir / "projects", scan_age)
|
||||
|
||||
if number(stats.get("totalPrompts")) <= 0:
|
||||
fallback = stats_cache_fallback(claude_dir)
|
||||
@@ -1242,19 +879,8 @@ def main() -> int:
|
||||
if opencode is not None:
|
||||
stats = merge_stats(stats, opencode)
|
||||
|
||||
registered = registered_accounts()
|
||||
accounts = [account_limits(account, args.force) for account in registered]
|
||||
current = next((a for a in accounts if a["active"]), None)
|
||||
if current:
|
||||
# The top-level fields keep describing the account new sessions use, so
|
||||
# the bar icon and anything else reading one set of limits stays right.
|
||||
plan = current["plan"]
|
||||
limits = {key: current[key] for key in ("limits", "usageStatusText", "authHelpText", "retryAdvised")}
|
||||
stale, fetched_at = current["stale"], current["fetchedAt"]
|
||||
else:
|
||||
access_token, expires_at_ms, refresh_expires_at_ms, plan = oauth_login(claude_dir)
|
||||
limits = collect_limits(access_token, expires_at_ms, refresh_expires_at_ms, args.force)
|
||||
stale, fetched_at = not limits["live"], limits["fetchedAtMs"]
|
||||
access_token, expires_at_ms, plan = oauth_login(claude_dir)
|
||||
limits = collect_limits(access_token, expires_at_ms, args.force)
|
||||
|
||||
record = {
|
||||
"schemaVersion": 1,
|
||||
@@ -1267,16 +893,9 @@ def main() -> int:
|
||||
"usageStatusText": limits["usageStatusText"],
|
||||
"authHelpText": limits["authHelpText"],
|
||||
"limits": limits["limits"],
|
||||
# Numbers kept past a failed probe, and when they were measured, so the
|
||||
# panel can tell them apart from ones checked just now.
|
||||
"limitsStale": stale,
|
||||
"limitsFetchedAt": fetched_at,
|
||||
}
|
||||
if limits.get("retryAdvised"):
|
||||
record["retryAdvised"] = True
|
||||
if accounts:
|
||||
record["accountSwitch"] = registered[0]["switch"]
|
||||
record["accounts"] = accounts
|
||||
record.update(stats)
|
||||
print(json.dumps(record, separators=(",", ":"), sort_keys=True))
|
||||
return 0
|
||||
|
||||
+109
-583
@@ -22,7 +22,6 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import tomllib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -30,11 +29,6 @@ AGENT_ID = "codex"
|
||||
AGENT_NAME = "Codex"
|
||||
AUTH_HELP = "Run `codex login` to authenticate."
|
||||
|
||||
# session_meta.model_provider for the built-in OpenAI backend, which is the
|
||||
# only one this subscription pays for. Any other id points somewhere else:
|
||||
# `--oss`'s ollama or lmstudio, or a custom provider in config.toml.
|
||||
NATIVE_PROVIDER = "openai"
|
||||
|
||||
# A scan this recent is only reused to dedup concurrent collector runs (the
|
||||
# update command backgrounds one per agent while the panel refreshes on its
|
||||
# own); every periodic widget refresh lands a real rescan, however low
|
||||
@@ -43,11 +37,6 @@ NATIVE_PROVIDER = "openai"
|
||||
SCAN_REUSE_SECONDS = 20
|
||||
LIMITS_ONLY_REUSE_SECONDS = 900
|
||||
|
||||
# Per-file totals of native Codex sessions survive between runs, so a rescan
|
||||
# only reads the session files that changed. Bumping the schema retires every
|
||||
# record written by an older layout.
|
||||
FILE_CACHE_SCHEMA = 1
|
||||
|
||||
|
||||
def local_day(value):
|
||||
if value is None:
|
||||
@@ -102,52 +91,6 @@ def find_command(name):
|
||||
return shutil.which(name, path=ENV.get("PATH"))
|
||||
|
||||
|
||||
def mise_shims_dir():
|
||||
data_home = ENV.get("MISE_DATA_DIR") or os.path.join(
|
||||
ENV.get("XDG_DATA_HOME") or os.path.join(str(Path.home()), ".local", "share"), "mise")
|
||||
return os.path.join(data_home, "shims")
|
||||
|
||||
|
||||
def is_lazy_launcher(path):
|
||||
# omarchy-mise-install writes a regular file whose first step is `mise use
|
||||
# -g`, and mise's shims exec `mise x`; running either installs the tool it
|
||||
# wraps. A shim is a symlink to the mise binary itself, so the symlink
|
||||
# check comes after the shim checks: a symlink elsewhere is the user's own
|
||||
# binary.
|
||||
if os.path.dirname(os.path.abspath(path)) == mise_shims_dir():
|
||||
return True
|
||||
if os.path.islink(path):
|
||||
mise = find_command("mise")
|
||||
return bool(mise) and os.path.realpath(path) == os.path.realpath(mise)
|
||||
try:
|
||||
with open(path, "rb") as handle:
|
||||
head = handle.read(4096)
|
||||
return b"mise use" in head or b"mise x " in head
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def find_codex_binary():
|
||||
for directory in ENV.get("PATH", "").split(os.pathsep):
|
||||
candidate = os.path.join(directory, "codex")
|
||||
if os.path.isfile(candidate) and os.access(candidate, os.X_OK) and not is_lazy_launcher(candidate):
|
||||
return candidate
|
||||
# `mise which` only prints a binary that is already installed, so it is the
|
||||
# safe way to resolve a codex managed by mise without running the launcher.
|
||||
# `mise which` can resolve a configured `latest` over the network; the usage
|
||||
# probe stays offline.
|
||||
mise = find_command("mise")
|
||||
if mise:
|
||||
try:
|
||||
out = subprocess.run([mise, "which", "codex"], capture_output=True, text=True, env=dict(ENV, MISE_OFFLINE="1"), timeout=10)
|
||||
resolved = out.stdout.strip()
|
||||
if out.returncode == 0 and os.path.isfile(resolved) and not is_lazy_launcher(resolved):
|
||||
return resolved
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
now = datetime.now()
|
||||
today = now.strftime("%Y-%m-%d")
|
||||
recent_dates = [(now - timedelta(days=offset)).strftime("%Y-%m-%d") for offset in range(6, -1, -1)]
|
||||
@@ -164,109 +107,46 @@ total_sessions = set()
|
||||
seen_pi_messages = set()
|
||||
|
||||
|
||||
# One session file's totals, the unit the file cache stores: day -> model ->
|
||||
# [input, output, cacheRead, cacheWrite, prompts]. Day-keyed and with no
|
||||
# "today" in it, so a record stays true however long it is kept.
|
||||
def new_file_record(stat_result):
|
||||
return {"mtime": stat_result.st_mtime, "size": stat_result.st_size, "days": {}}
|
||||
|
||||
|
||||
def record_usage(record, day, model, input_tokens, output_tokens, cache_read, cache_write):
|
||||
totals = record["days"].setdefault(day, {}).setdefault(model, [0, 0, 0, 0, 0])
|
||||
totals[0] += input_tokens
|
||||
totals[1] += output_tokens
|
||||
totals[2] += cache_read
|
||||
totals[3] += cache_write
|
||||
totals[4] += 1
|
||||
|
||||
|
||||
def merge_file_record(session_key, record):
|
||||
"""Fold one file's totals, freshly read or replayed from cache, into the run."""
|
||||
global today_prompts, today_total_tokens, total_prompts
|
||||
for day, models in record["days"].items():
|
||||
for model, totals in models.items():
|
||||
input_tokens, output_tokens, cache_read, cache_write, prompts = totals
|
||||
total = input_tokens + output_tokens + cache_read + cache_write
|
||||
total_prompts += prompts
|
||||
total_sessions.add(session_key)
|
||||
active_days.add(day)
|
||||
|
||||
bucket = model_usage.setdefault(model, {
|
||||
"inputTokens": 0,
|
||||
"outputTokens": 0,
|
||||
"cacheReadInputTokens": 0,
|
||||
"cacheCreationInputTokens": 0,
|
||||
})
|
||||
bucket["inputTokens"] += input_tokens
|
||||
bucket["outputTokens"] += output_tokens
|
||||
bucket["cacheReadInputTokens"] += cache_read
|
||||
bucket["cacheCreationInputTokens"] += cache_write
|
||||
|
||||
if day in recent:
|
||||
recent[day]["messageCount"] += total
|
||||
|
||||
if day == today:
|
||||
today_prompts += prompts
|
||||
today_sessions.add(session_key)
|
||||
today_total_tokens += total
|
||||
today_tokens_by_model[model] = today_tokens_by_model.get(model, 0) + total
|
||||
|
||||
|
||||
def add_usage(day, session_key, model, input_tokens, output_tokens, cache_read, cache_write):
|
||||
record = {"days": {}}
|
||||
record_usage(record, day, model, input_tokens, output_tokens, cache_read, cache_write)
|
||||
merge_file_record(session_key, record)
|
||||
global today_prompts, today_total_tokens, total_prompts
|
||||
total = input_tokens + output_tokens + cache_read + cache_write
|
||||
total_prompts += 1
|
||||
total_sessions.add(session_key)
|
||||
active_days.add(day)
|
||||
|
||||
bucket = model_usage.setdefault(model, {
|
||||
"inputTokens": 0,
|
||||
"outputTokens": 0,
|
||||
"cacheReadInputTokens": 0,
|
||||
"cacheCreationInputTokens": 0,
|
||||
})
|
||||
bucket["inputTokens"] += input_tokens
|
||||
bucket["outputTokens"] += output_tokens
|
||||
bucket["cacheReadInputTokens"] += cache_read
|
||||
bucket["cacheCreationInputTokens"] += cache_write
|
||||
|
||||
def pi_parent_session(path):
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as session_file:
|
||||
header = json.loads(session_file.readline())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if day in recent:
|
||||
recent[day]["messageCount"] += total
|
||||
|
||||
if not isinstance(header, dict):
|
||||
return None
|
||||
parent = header.get("parentSession")
|
||||
if not isinstance(parent, str) or not parent:
|
||||
return None
|
||||
return os.path.abspath(os.path.expanduser(parent))
|
||||
|
||||
|
||||
# `omp --profile=<name>` (and pi's equivalent) relocates the whole agent tree
|
||||
# under <base>/profiles/<name>/, so a subscription driven entirely through a
|
||||
# profile leaves the default root empty and its usage uncounted. Each root is
|
||||
# scanned separately and sessions are keyed by path, so a profile adds
|
||||
# sessions rather than double-counting the default one.
|
||||
def pi_session_roots():
|
||||
roots = []
|
||||
for base in (Path.home() / ".pi", Path.home() / ".omp"):
|
||||
roots.append(base / "agent" / "sessions")
|
||||
profiles = base / "profiles"
|
||||
try:
|
||||
# Sorted so the scan order does not depend on directory order.
|
||||
roots.extend(sorted(child / "agent" / "sessions" for child in profiles.iterdir() if child.is_dir()))
|
||||
except OSError:
|
||||
# No profiles directory, or it is unreadable: the default root stands.
|
||||
pass
|
||||
return roots
|
||||
if day == today:
|
||||
today_prompts += 1
|
||||
today_sessions.add(session_key)
|
||||
today_total_tokens += total
|
||||
today_tokens_by_model[model] = today_tokens_by_model.get(model, 0) + total
|
||||
|
||||
|
||||
def scan_pi_sessions():
|
||||
roots = pi_session_roots()
|
||||
roots = [
|
||||
Path.home() / ".pi" / "agent" / "sessions",
|
||||
Path.home() / ".omp" / "agent" / "sessions",
|
||||
]
|
||||
rg = find_command("rg") or "rg"
|
||||
session_keys = {}
|
||||
today_session_keys = {}
|
||||
for root in roots:
|
||||
if not root.exists():
|
||||
continue
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
# --no-ignore: these are data files, not a source tree. When $HOME is
|
||||
# itself a git checkout (a common dotfiles setup), the parent repo's
|
||||
# ignore rules would otherwise silently exclude every session file and
|
||||
# the scan would count zero usage.
|
||||
[rg, "--json", "--no-ignore", "-e", r'"provider"\s*:\s*"openai-codex"', "-e", r'"api"\s*:\s*"openai-codex', str(root)],
|
||||
[rg, "--json", "-e", r'"provider"\s*:\s*"openai-codex"', "-e", r'"api"\s*:\s*"openai-codex', str(root)],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
@@ -283,22 +163,16 @@ def scan_pi_sessions():
|
||||
if event.get("type") != "match":
|
||||
continue
|
||||
line = event.get("data", {}).get("lines", {}).get("text", "")
|
||||
raw_path = event.get("data", {}).get("path", {}).get("text", "pi-session")
|
||||
path = os.path.abspath(os.path.expanduser(raw_path))
|
||||
path = event.get("data", {}).get("path", {}).get("text", "pi-session")
|
||||
entry = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if entry.get("type") != "message":
|
||||
continue
|
||||
message_id = str(entry.get("id") or "")
|
||||
message_timestamp = str(entry.get("timestamp") or "")
|
||||
# Forks retain both fields; IDs alone can collide across sessions.
|
||||
if message_id and message_timestamp:
|
||||
message_key = ("entry", message_id, message_timestamp)
|
||||
else:
|
||||
message_key = ("path", path, message_id)
|
||||
duplicate = message_key in seen_pi_messages
|
||||
message_key = path + ":" + str(entry.get("id") or "")
|
||||
if message_key in seen_pi_messages:
|
||||
continue
|
||||
seen_pi_messages.add(message_key)
|
||||
message = entry.get("message") or {}
|
||||
if message.get("role") != "assistant":
|
||||
@@ -322,37 +196,14 @@ def scan_pi_sessions():
|
||||
continue
|
||||
|
||||
day = local_day(entry.get("timestamp") or message.get("timestamp"))
|
||||
session_keys.setdefault(path, set()).add(message_key)
|
||||
if day == today:
|
||||
today_session_keys.setdefault(path, set()).add(message_key)
|
||||
if duplicate:
|
||||
continue
|
||||
add_usage(day, path, model_name(message.get("model")), input_tokens, output_tokens, cache_read, cache_write)
|
||||
session_key = path
|
||||
add_usage(day, session_key, model_name(message.get("model")), input_tokens, output_tokens, cache_read, cache_write)
|
||||
|
||||
try:
|
||||
proc.wait(timeout=1)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
|
||||
# Resolve session ownership after scanning so rg's file order cannot affect it.
|
||||
parents = {path: pi_parent_session(path) for path in session_keys}
|
||||
total_sessions.difference_update(session_keys)
|
||||
today_sessions.difference_update(session_keys)
|
||||
for path, keys in session_keys.items():
|
||||
inherited = set()
|
||||
visited = {path}
|
||||
parent = parents.get(path)
|
||||
while parent and parent not in visited:
|
||||
visited.add(parent)
|
||||
inherited.update(session_keys.get(parent, ()))
|
||||
parent = parents.get(parent)
|
||||
|
||||
unique_keys = keys - inherited
|
||||
if unique_keys:
|
||||
total_sessions.add(path)
|
||||
if unique_keys & today_session_keys.get(path, set()):
|
||||
today_sessions.add(path)
|
||||
|
||||
|
||||
def scan_opencode_sessions():
|
||||
# A subscription burned entirely through opencode leaves no native session
|
||||
@@ -432,7 +283,7 @@ def scan_opencode_sessions():
|
||||
return True
|
||||
|
||||
|
||||
def scan_native_codex_sessions(cached_files, scanned_files):
|
||||
def scan_native_codex_sessions():
|
||||
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
||||
roots = [codex_home / "sessions", codex_home / "archived_sessions"]
|
||||
files = []
|
||||
@@ -442,98 +293,48 @@ def scan_native_codex_sessions(cached_files, scanned_files):
|
||||
continue
|
||||
for path in root.rglob("*.jsonl"):
|
||||
try:
|
||||
stat_result = path.stat()
|
||||
if path.stat().st_mtime >= cutoff:
|
||||
files.append(path)
|
||||
except OSError:
|
||||
continue
|
||||
if stat_result.st_mtime >= cutoff:
|
||||
files.append((path, stat_result))
|
||||
pass
|
||||
|
||||
for path, stat_result in files:
|
||||
key = str(path)
|
||||
record = reusable_record(cached_files, key, stat_result)
|
||||
if record is None:
|
||||
record = read_native_codex_session(path, stat_result)
|
||||
if record["mtime"] is not None:
|
||||
scanned_files[key] = record
|
||||
merge_file_record(key, record)
|
||||
|
||||
|
||||
def read_native_codex_session(path, stat_result):
|
||||
record = new_file_record(stat_result)
|
||||
current_model = "codex"
|
||||
seen_meta = False
|
||||
previous_total_usage = None
|
||||
try:
|
||||
with path.open(errors="replace") as handle:
|
||||
for raw in handle:
|
||||
# Cheap pre-filter before JSON parsing keeps files with unrelated
|
||||
# lines inexpensive. session_meta still has to get through: it says
|
||||
# which provider served the rollout.
|
||||
if '"token_count"' not in raw and '"turn_context"' not in raw and '"session_meta"' not in raw:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
if entry.get("type") == "session_meta":
|
||||
# A forked rollout copies its parent's session_meta after its own,
|
||||
# so only the first one, as Codex reads it, says who served this.
|
||||
if seen_meta:
|
||||
for path in files:
|
||||
current_model = "codex"
|
||||
try:
|
||||
with path.open(errors="replace") as handle:
|
||||
for raw in handle:
|
||||
try:
|
||||
entry = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
seen_meta = True
|
||||
# Codex CLI happily fronts any OpenAI-compatible backend (`--oss`,
|
||||
# or a custom model_provider in config.toml). Those turns bill the
|
||||
# local box or a third party, never this subscription, so drop the
|
||||
# whole rollout the way the pi and opencode scans drop foreign
|
||||
# providers.
|
||||
meta = entry.get("payload") or {}
|
||||
provider = str(meta.get("model_provider") or "")
|
||||
# Rollouts predating the field carry no provider at all; count
|
||||
# those rather than silently lose the history.
|
||||
if provider and provider != NATIVE_PROVIDER:
|
||||
break
|
||||
continue
|
||||
if entry.get("type") == "turn_context":
|
||||
payload = entry.get("payload") or {}
|
||||
current_model = model_name(payload.get("model") or payload.get("model_slug") or current_model)
|
||||
continue
|
||||
payload = entry.get("payload") or entry
|
||||
if entry.get("type") == "response_item" and isinstance(payload, dict):
|
||||
payload = payload.get("payload") or payload
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
if payload.get("type") != "token_count":
|
||||
continue
|
||||
info = payload.get("info") or {}
|
||||
# total_token_usage is cumulative for the session. Adding every
|
||||
# snapshot makes usage grow quadratically, so count the last turn.
|
||||
usage = info.get("last_token_usage") or {}
|
||||
cache_read = number(usage.get("cached_input_tokens"))
|
||||
cache_write = number(usage.get("cache_write_input_tokens"))
|
||||
# Cached tokens are included in input_tokens, and reasoning tokens
|
||||
# are included in output_tokens. Keep the cache split without
|
||||
# counting either category twice.
|
||||
input_tokens = max(0, number(usage.get("input_tokens")) - cache_read - cache_write)
|
||||
output_tokens = number(usage.get("output_tokens"))
|
||||
if not (input_tokens or output_tokens or cache_read or cache_write):
|
||||
continue
|
||||
# Quota updates can repeat the previous request's last_token_usage.
|
||||
# Only an unchanged cumulative snapshot proves this is a repeat:
|
||||
# separate requests may have identical last-token counts, and older
|
||||
# records may not carry cumulative counters at all. Keep this state
|
||||
# per rollout and accept changed counters, including counter resets.
|
||||
total_usage = info.get("total_token_usage")
|
||||
if isinstance(total_usage, dict) and total_usage:
|
||||
if total_usage == previous_total_usage:
|
||||
if entry.get("type") == "turn_context":
|
||||
payload = entry.get("payload") or {}
|
||||
current_model = model_name(payload.get("model") or payload.get("model_slug") or current_model)
|
||||
continue
|
||||
previous_total_usage = total_usage
|
||||
day = local_day(entry.get("timestamp") or stat_result.st_mtime)
|
||||
record_usage(record, day, current_model, input_tokens, output_tokens, cache_read, cache_write)
|
||||
except Exception:
|
||||
# Keep what was read before the failure, but caching a partial record would
|
||||
# hide the rest until the file changes, so hand back one the cache refuses.
|
||||
record["mtime"] = None
|
||||
return record
|
||||
payload = entry.get("payload") or entry
|
||||
if entry.get("type") == "response_item" and isinstance(payload, dict):
|
||||
payload = payload.get("payload") or payload
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
if payload.get("type") != "token_count":
|
||||
continue
|
||||
info = payload.get("info") or {}
|
||||
# total_token_usage is cumulative for the session. Adding every
|
||||
# snapshot makes usage grow quadratically, so count the last turn.
|
||||
usage = info.get("last_token_usage") or {}
|
||||
cache_read = number(usage.get("cached_input_tokens"))
|
||||
cache_write = number(usage.get("cache_write_input_tokens"))
|
||||
# Cached tokens are included in input_tokens, and reasoning tokens
|
||||
# are included in output_tokens. Keep the cache split without
|
||||
# counting either category twice.
|
||||
input_tokens = max(0, number(usage.get("input_tokens")) - cache_read - cache_write)
|
||||
output_tokens = number(usage.get("output_tokens"))
|
||||
if not (input_tokens or output_tokens or cache_read or cache_write):
|
||||
continue
|
||||
day = local_day(entry.get("timestamp") or path.stat().st_mtime)
|
||||
add_usage(day, str(path), current_model, input_tokens, output_tokens, cache_read, cache_write)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
def cache_root():
|
||||
@@ -542,86 +343,16 @@ def cache_root():
|
||||
return root
|
||||
|
||||
|
||||
def scan_cache_digest():
|
||||
def scan_cache_paths():
|
||||
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
||||
db = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share")) / "opencode" / "opencode.db"
|
||||
# The digest covers every data path the scan reads: the codex session
|
||||
# roots, the opencode DB, and (via Path.home()) the pi/omp session roots.
|
||||
return hashlib.sha1((str(Path.home()) + "\n" + str(codex_home) + "\n" + str(db)).encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def scan_cache_paths():
|
||||
digest = scan_cache_digest()
|
||||
digest = hashlib.sha1((str(Path.home()) + "\n" + str(codex_home) + "\n" + str(db)).encode("utf-8")).hexdigest()[:16]
|
||||
root = cache_root()
|
||||
return root / f"codex-scan-{digest}.json", root / f"codex-scan-{digest}.lock"
|
||||
|
||||
|
||||
# Records hold local days, so they are only true in the timezone that wrote them.
|
||||
def file_cache_zone():
|
||||
return [os.environ.get("TZ"), list(time.tzname), time.timezone, time.altzone]
|
||||
|
||||
|
||||
def file_cache_path():
|
||||
return cache_root() / f"codex-files-{scan_cache_digest()}.json"
|
||||
|
||||
|
||||
def valid_file_record(value):
|
||||
try:
|
||||
counts = [totals for models in value["days"].values() for totals in models.values()]
|
||||
return (
|
||||
type(value["mtime"]) in (int, float) and type(value["size"]) is int
|
||||
and all(len(totals) == 5 and all(type(n) is int for n in totals) for totals in counts)
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def read_file_cache():
|
||||
"""Per-file totals of native sessions from earlier scans, keyed by path.
|
||||
|
||||
Session files are append-only, so a file whose mtime and size both match the
|
||||
record cannot have grown a turn since: its totals can be replayed instead of
|
||||
read. That is what keeps a refresh off the gigabytes of history it already
|
||||
counted. Unlike the aggregate scan cache these records carry no "today" and
|
||||
never expire on their own -- only the file they describe or a change of
|
||||
timezone can invalidate one, so midnight and the clock moving leave them true.
|
||||
|
||||
pi and omp sessions stay out of it: a forked pi session repeats its parent's
|
||||
messages, so they are only deduplicated across the whole tree at once.
|
||||
"""
|
||||
try:
|
||||
payload = json.loads(file_cache_path().read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
if not isinstance(payload, dict) or payload.get("schemaVersion") != FILE_CACHE_SCHEMA:
|
||||
return {}
|
||||
if payload.get("zone") != file_cache_zone():
|
||||
return {}
|
||||
files = payload.get("files")
|
||||
if not isinstance(files, dict):
|
||||
return {}
|
||||
return {path: record for path, record in files.items() if valid_file_record(record)}
|
||||
|
||||
|
||||
def write_file_cache(files):
|
||||
# Only the files this scan saw are written back, so deleted sessions and
|
||||
# sessions that aged out of the 30-day window leave the cache instead of
|
||||
# growing it forever.
|
||||
try:
|
||||
write_json(file_cache_path(), {"schemaVersion": FILE_CACHE_SCHEMA, "zone": file_cache_zone(), "files": files})
|
||||
except Exception as exc:
|
||||
print(f"omarchy-agent-usage-codex: could not write file cache ({exc})", file=sys.stderr)
|
||||
|
||||
|
||||
def reusable_record(cached_files, key, stat_result):
|
||||
record = cached_files.get(key)
|
||||
if record is None:
|
||||
return None
|
||||
if record["mtime"] != stat_result.st_mtime or record["size"] != stat_result.st_size:
|
||||
return None
|
||||
return record
|
||||
|
||||
|
||||
def read_fresh_json(path, max_age_seconds):
|
||||
if max_age_seconds <= 0 or not path.exists():
|
||||
return None
|
||||
@@ -658,10 +389,9 @@ def write_json(path, payload):
|
||||
# The cache payload is a versioned envelope around the local-stats dict, so a
|
||||
# corrupted or foreign-shaped file is a cache miss (rescan + rewrite) instead
|
||||
# of a crash or a garbage record.
|
||||
# Version 2 invalidates totals counted before native notification deduplication.
|
||||
def read_cached_stats(cache_file, max_age_seconds):
|
||||
cached = read_fresh_json(cache_file, max_age_seconds)
|
||||
if not isinstance(cached, dict) or cached.get("schemaVersion") != 2:
|
||||
if not isinstance(cached, dict) or cached.get("schemaVersion") != 1:
|
||||
return None
|
||||
# today* fields only mean "today" on the day they were scanned. A cache
|
||||
# from another local date (midnight passed, or the clock moved) is a miss,
|
||||
@@ -678,7 +408,7 @@ def read_cached_stats(cache_file, max_age_seconds):
|
||||
|
||||
def write_cached_stats(cache_file, stats):
|
||||
try:
|
||||
write_json(cache_file, {"schemaVersion": 2, "scanDate": today, "stats": stats})
|
||||
write_json(cache_file, {"schemaVersion": 1, "scanDate": today, "stats": stats})
|
||||
except Exception as exc:
|
||||
print(f"omarchy-agent-usage-codex: could not write usage cache ({exc})", file=sys.stderr)
|
||||
|
||||
@@ -702,14 +432,10 @@ def local_stats():
|
||||
}
|
||||
|
||||
|
||||
# A forced scan (no reuse at all) reads every session file again.
|
||||
def run_local_scans(max_age):
|
||||
cached_files = read_file_cache() if max_age > 0 else {}
|
||||
scanned_files = {}
|
||||
def run_local_scans():
|
||||
scan_pi_sessions()
|
||||
scan_native_codex_sessions(cached_files, scanned_files)
|
||||
scan_native_codex_sessions()
|
||||
complete = scan_opencode_sessions()
|
||||
write_file_cache(scanned_files)
|
||||
return local_stats(), complete
|
||||
|
||||
|
||||
@@ -724,7 +450,7 @@ def cached_local_stats(max_age):
|
||||
return _cached_local_stats(max_age)
|
||||
except Exception as exc:
|
||||
print(f"omarchy-agent-usage-codex: cache unavailable ({exc}); scanning directly", file=sys.stderr)
|
||||
stats, _ = run_local_scans(max_age)
|
||||
stats, _ = run_local_scans()
|
||||
return stats
|
||||
|
||||
|
||||
@@ -740,7 +466,7 @@ def _cached_local_stats(max_age):
|
||||
cached = read_cached_stats(cache_file, max_age)
|
||||
if cached is not None:
|
||||
return cached
|
||||
stats, complete = run_local_scans(max_age)
|
||||
stats, complete = run_local_scans()
|
||||
# An interrupted scan still serves this run, but caching it would
|
||||
# suppress the missing usage for every reader until the cache expires.
|
||||
if complete:
|
||||
@@ -748,55 +474,25 @@ def _cached_local_stats(max_age):
|
||||
return stats
|
||||
|
||||
|
||||
def rpc_send(proc, payload, operation):
|
||||
"""Write one JSON-RPC frame. Raise immediately if the app-server is gone."""
|
||||
try:
|
||||
proc.stdin.write(json.dumps(payload) + "\n")
|
||||
proc.stdin.flush()
|
||||
except OSError as exc:
|
||||
try:
|
||||
proc.stdin.close()
|
||||
except OSError:
|
||||
pass
|
||||
raise RuntimeError(f"Codex app-server exited before {operation}") from exc
|
||||
|
||||
|
||||
def rpc_request(proc, request_id, method, params=None, timeout=8):
|
||||
payload = {"id": request_id, "method": method, "params": params or {}}
|
||||
rpc_send(proc, payload, method)
|
||||
# Read the raw fd and split lines ourselves. The app-server can emit
|
||||
# notifications in the same write as a reply; a buffered readline() would
|
||||
# pull them all into Python's buffer where select() cannot see them, and
|
||||
# the reply would sit there until the deadline.
|
||||
fd = proc.stdout.fileno()
|
||||
pending = getattr(proc, "_rpc_pending", b"")
|
||||
deadline = time.monotonic() + timeout
|
||||
try:
|
||||
while True:
|
||||
while b"\n" in pending:
|
||||
line, pending = pending.split(b"\n", 1)
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(message, dict) and message.get("id") == request_id:
|
||||
return message
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
ready, _, _ = select.select([fd], [], [], min(0.25, remaining))
|
||||
if not ready:
|
||||
if proc.poll() is not None:
|
||||
raise RuntimeError(f"Codex app-server exited before {method}")
|
||||
continue
|
||||
chunk = os.read(fd, 65536)
|
||||
if not chunk:
|
||||
raise RuntimeError(f"Codex app-server exited before {method}")
|
||||
pending += chunk
|
||||
finally:
|
||||
proc._rpc_pending = pending
|
||||
# Process still up but silent: keep the method name searchable, but name the stall.
|
||||
raise TimeoutError(f"Codex app-server did not answer {method}")
|
||||
proc.stdin.write(json.dumps(payload) + "\n")
|
||||
proc.stdin.flush()
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
ready, _, _ = select.select([proc.stdout], [], [], 0.25)
|
||||
if not ready:
|
||||
continue
|
||||
line = proc.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if message.get("id") == request_id:
|
||||
return message
|
||||
raise TimeoutError(method)
|
||||
|
||||
|
||||
def limit_window(window):
|
||||
@@ -822,153 +518,47 @@ def limit_window(window):
|
||||
}
|
||||
|
||||
|
||||
def codex_rpc_help(proc, stderr_file, exc):
|
||||
"""Explain an app-server RPC failure: a live server's stall, a dead one's own
|
||||
stderr, or the login hint when it died saying nothing."""
|
||||
try:
|
||||
proc.wait(timeout=1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
text = str(exc).strip()
|
||||
if proc.poll() is None:
|
||||
# Still up: stall or unparseable payload. Not an auth problem, and any
|
||||
# stderr so far is its logging, not why it stopped.
|
||||
return text[:300] or AUTH_HELP
|
||||
|
||||
try:
|
||||
stderr_file.seek(0)
|
||||
detail = stderr_file.read()
|
||||
except Exception:
|
||||
detail = ""
|
||||
lines = [line.strip() for line in detail.splitlines() if line.strip()]
|
||||
if lines:
|
||||
# The fatal error comes last, after whatever the CLI logged on the way up.
|
||||
return f"codex app-server exited: {' '.join(lines)[-275:]}"
|
||||
# Exited cleanly with empty stderr — typical "not logged in" shape.
|
||||
return AUTH_HELP
|
||||
|
||||
|
||||
def reset_credits(result):
|
||||
granted = (result.get("rateLimitResetCredits") or {}).get("credits") or []
|
||||
available = [c for c in granted if isinstance(c, dict) and c.get("status") == "available"]
|
||||
if not available:
|
||||
return None
|
||||
expiries = [number(c.get("expiresAt")) for c in available if number(c.get("expiresAt")) > 0]
|
||||
return {
|
||||
"available": len(available),
|
||||
"nextExpiresAt": datetime.fromtimestamp(min(expiries), timezone.utc).isoformat() if expiries else "",
|
||||
}
|
||||
|
||||
|
||||
# Keyring and auto stores keep credentials outside auth.json, so only the file
|
||||
# store can be known to be empty without asking Codex.
|
||||
def has_codex_credentials(codex_home, env):
|
||||
if env.get("CODEX_ACCESS_TOKEN"):
|
||||
return True
|
||||
try:
|
||||
with (codex_home / "config.toml").open("rb") as handle:
|
||||
config = tomllib.load(handle)
|
||||
except FileNotFoundError:
|
||||
config = {}
|
||||
except Exception:
|
||||
# A config this can't read may still name another store; let Codex say.
|
||||
return True
|
||||
if str(config.get("cli_auth_credentials_store") or "file") != "file":
|
||||
return True
|
||||
return (codex_home / "auth.json").is_file()
|
||||
|
||||
|
||||
def fetch_codex_rpc(home=None):
|
||||
def fetch_codex_rpc():
|
||||
result = {"limits": [], "tierLabel": "", "usageStatusText": "", "authHelpText": AUTH_HELP}
|
||||
env = dict(ENV, CODEX_HOME=str(home)) if home else ENV
|
||||
codex = find_codex_binary()
|
||||
codex = find_command("codex")
|
||||
if not codex:
|
||||
result["usageStatusText"] = "Codex unavailable"
|
||||
result["authHelpText"] = "codex not found in PATH"
|
||||
return result
|
||||
|
||||
# No auth.json under the file store and no CODEX_ACCESS_TOKEN means
|
||||
# account/read can only fail — and starting the app-server is not free: it
|
||||
# syncs the plugin list, a git fetch per refresh that also leaves
|
||||
# .tmp/git-* folders behind. The home is the one being probed, so a
|
||||
# signed-out secondary account reads as signed out even while ~/.codex
|
||||
# holds a login.
|
||||
if not has_codex_credentials(Path(env.get("CODEX_HOME") or (Path.home() / ".codex")), env):
|
||||
result["usageStatusText"] = "Waiting for auth"
|
||||
result["authHelpText"] = AUTH_HELP
|
||||
return result
|
||||
|
||||
# stderr goes to a regular file, not a PIPE: an unread pipe can fill and
|
||||
# deadlock a chatty app-server. It is only read once the process has exited.
|
||||
# Without temporary space the probe still runs, just without its error text.
|
||||
try:
|
||||
stderr_file = tempfile.TemporaryFile("w+", errors="replace")
|
||||
except OSError:
|
||||
stderr_file = None
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
[codex, "-s", "read-only", "-a", "on-request", "app-server"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=stderr_file or subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
env=env,
|
||||
env=ENV,
|
||||
)
|
||||
except Exception as exc:
|
||||
if stderr_file:
|
||||
stderr_file.close()
|
||||
result["usageStatusText"] = "Codex unavailable"
|
||||
result["authHelpText"] = str(exc)
|
||||
return result
|
||||
|
||||
try:
|
||||
rpc_request(proc, 1, "initialize", {"clientInfo": {"name": "omarchy-agent-usage", "version": "1"}}, timeout=8)
|
||||
rpc_send(proc, {"method": "initialized", "params": {}}, "initialized")
|
||||
limits_msg = rpc_request(proc, 2, "account/rateLimits/read", timeout=8)
|
||||
proc.stdin.write(json.dumps({"method": "initialized", "params": {}}) + "\n")
|
||||
proc.stdin.flush()
|
||||
account_msg = rpc_request(proc, 2, "account/read", timeout=4)
|
||||
limits_msg = rpc_request(proc, 3, "account/rateLimits/read", timeout=4)
|
||||
|
||||
# A home nobody is signed in to answers with an error rather than limits.
|
||||
# Say so the same way the Claude collector does, so the panel offers to
|
||||
# sign that account in again.
|
||||
error = limits_msg.get("error")
|
||||
if isinstance(error, dict):
|
||||
message = str(error.get("message") or "")
|
||||
if "auth" in message.lower():
|
||||
result["usageStatusText"] = "Waiting for auth"
|
||||
result["authHelpText"] = AUTH_HELP
|
||||
else:
|
||||
result["usageStatusText"] = "Codex limits unavailable"
|
||||
result["authHelpText"] = message
|
||||
return result
|
||||
account = (account_msg.get("result") or {}).get("account") or {}
|
||||
limits = (limits_msg.get("result") or {}).get("rateLimits") or {}
|
||||
result["authHelpText"] = ""
|
||||
plan = limits.get("planType") or ""
|
||||
|
||||
# The limits name the plan themselves. account/read is only a fallback
|
||||
# for when they don't, and never a reason to lose them: Codex 0.158's
|
||||
# app-server can leave it unanswered for good.
|
||||
if not plan:
|
||||
try:
|
||||
account_msg = rpc_request(proc, 3, "account/read", timeout=2)
|
||||
account = (account_msg.get("result") or {}).get("account") or {}
|
||||
plan = account.get("planType") or account.get("type") or ""
|
||||
except (TimeoutError, RuntimeError):
|
||||
pass
|
||||
plan = limits.get("planType") or account.get("planType") or account.get("type") or ""
|
||||
result["tierLabel"] = str(plan) if plan else ""
|
||||
|
||||
for window in (limits.get("primary"), limits.get("secondary")):
|
||||
entry = limit_window(window)
|
||||
if entry:
|
||||
result["limits"].append(entry)
|
||||
|
||||
# OpenAI hands out free "full reset" credits that wipe the rate limits on
|
||||
# demand and lapse after a month. Worth knowing about before a limit bites.
|
||||
credits = reset_credits(limits_msg.get("result") or {})
|
||||
if credits:
|
||||
result["resetCredits"] = credits
|
||||
except Exception as exc:
|
||||
result["usageStatusText"] = "Codex limits unavailable"
|
||||
result["authHelpText"] = codex_rpc_help(proc, stderr_file, exc)
|
||||
result["authHelpText"] = str(exc)
|
||||
finally:
|
||||
try:
|
||||
proc.terminate()
|
||||
@@ -978,56 +568,9 @@ def fetch_codex_rpc(home=None):
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
if stderr_file:
|
||||
stderr_file.close()
|
||||
return result
|
||||
|
||||
|
||||
# The accounts `omarchy agent account` registered, read straight from its
|
||||
# registry. Only a registry holding a second account matters: with one, the
|
||||
# record is exactly what it always was.
|
||||
def registered_accounts():
|
||||
state = Path(os.environ.get("XDG_STATE_HOME") or (Path.home() / ".local" / "state"))
|
||||
try:
|
||||
registry = json.loads((state / "omarchy" / "agents" / "accounts" / "codex.json").read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
accounts = [a for a in registry.get("accounts") or [] if isinstance(a, dict) and a.get("id")]
|
||||
if len(accounts) < 2:
|
||||
return []
|
||||
active_id = registry.get("active") or accounts[0]["id"]
|
||||
if not any(a["id"] == active_id for a in accounts):
|
||||
active_id = accounts[0]["id"]
|
||||
for account in accounts:
|
||||
account["active"] = account["id"] == active_id
|
||||
account["switch"] = {
|
||||
"mode": "auto" if registry.get("switch") == "auto" else "manual",
|
||||
"threshold": registry.get("threshold") or 95,
|
||||
}
|
||||
return accounts
|
||||
|
||||
|
||||
# Each account's app-server runs in that account's home, so it answers for that
|
||||
# login and refreshes that login's tokens there.
|
||||
def account_limits(account):
|
||||
# The primary is ~/.codex by definition, not whatever CODEX_HOME this run
|
||||
# happened to inherit — that would report another account's limits as Main's.
|
||||
rpc = fetch_codex_rpc(account.get("home") or (Path.home() / ".codex"))
|
||||
return {
|
||||
"id": account["id"],
|
||||
"label": str(account.get("label") or account["id"]),
|
||||
"email": str(account.get("email") or ""),
|
||||
"plan": rpc["tierLabel"] or str(account.get("plan") or ""),
|
||||
"active": account["active"],
|
||||
"primary": bool(account.get("primary")),
|
||||
"limits": rpc["limits"],
|
||||
"stale": not rpc["limits"],
|
||||
"usageStatusText": rpc["usageStatusText"],
|
||||
"authHelpText": rpc["authHelpText"],
|
||||
"resetCredits": rpc.get("resetCredits"),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
# --force rescans everything and rewrites the cache. --limits-only is kept
|
||||
@@ -1041,21 +584,7 @@ def main():
|
||||
|
||||
max_age = 0 if args.force else (LIMITS_ONLY_REUSE_SECONDS if args.limits_only else SCAN_REUSE_SECONDS)
|
||||
stats = cached_local_stats(max_age)
|
||||
registered = registered_accounts()
|
||||
accounts = [account_limits(account) for account in registered]
|
||||
current = next((a for a in accounts if a["active"]), None)
|
||||
if current:
|
||||
# The top-level fields keep describing the account new sessions use.
|
||||
rpc = {
|
||||
"limits": current["limits"],
|
||||
"tierLabel": current["plan"],
|
||||
"usageStatusText": current["usageStatusText"],
|
||||
"authHelpText": current["authHelpText"],
|
||||
}
|
||||
if current.get("resetCredits"):
|
||||
rpc["resetCredits"] = current["resetCredits"]
|
||||
else:
|
||||
rpc = fetch_codex_rpc()
|
||||
rpc = fetch_codex_rpc()
|
||||
|
||||
record = {
|
||||
"schemaVersion": 1,
|
||||
@@ -1067,9 +596,6 @@ def main():
|
||||
}
|
||||
record.update(stats)
|
||||
record.update(rpc)
|
||||
if accounts:
|
||||
record["accountSwitch"] = registered[0]["switch"]
|
||||
record["accounts"] = accounts
|
||||
print(json.dumps(record, separators=(",", ":")))
|
||||
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ from typing import Any
|
||||
|
||||
AGENT_ID = "fireworks"
|
||||
AGENT_NAME = "Fireworks"
|
||||
AUTH_HELP = "Set FIREWORKS_API_KEY, run `firectl set-api-key`, or sign in to Fireworks in pi or opencode."
|
||||
AUTH_HELP = "Set FIREWORKS_API_KEY, run `firectl set-api-key`, or sign in to Fireworks in opencode."
|
||||
API_BASE_URL = "https://api.fireworks.ai"
|
||||
|
||||
|
||||
@@ -196,57 +196,11 @@ def read_auth_file(path: Path) -> tuple[str, str]:
|
||||
return api_key, account_id
|
||||
|
||||
|
||||
def pi_auth_path() -> Path:
|
||||
agent_dir = Path(os.environ.get("PI_CODING_AGENT_DIR") or (Path.home() / ".pi" / "agent"))
|
||||
return agent_dir / "auth.json"
|
||||
|
||||
|
||||
def opencode_auth_path() -> Path:
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share"))
|
||||
return data_home / "opencode" / "auth.json"
|
||||
|
||||
|
||||
PI_ENV_REFERENCE = re.compile(r"\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))")
|
||||
|
||||
|
||||
def resolve_pi_key(raw: Any) -> str:
|
||||
# pi credential keys are literals, $ENV_VAR/${ENV_VAR} references, or
|
||||
# !command shell lookups. The collector resolves the first two; command
|
||||
# lookups stay pi-only and are skipped rather than sent to the API verbatim.
|
||||
key = str(raw or "").strip()
|
||||
if not key or key.startswith("!"):
|
||||
return ""
|
||||
# Escapes survive interpolation as sentinels, then restore.
|
||||
key = key.replace("$$", "\x00").replace("$!", "\x01")
|
||||
|
||||
unresolved = False
|
||||
|
||||
def substitute(match: re.Match[str]) -> str:
|
||||
nonlocal unresolved
|
||||
value = os.environ.get(match.group(1) or match.group(2), "")
|
||||
if not value:
|
||||
unresolved = True
|
||||
return value
|
||||
|
||||
key = PI_ENV_REFERENCE.sub(substitute, key)
|
||||
if unresolved:
|
||||
return ""
|
||||
return key.replace("\x00", "$").replace("\x01", "!")
|
||||
|
||||
|
||||
def read_pi_key(path: Path) -> str:
|
||||
try:
|
||||
parsed = json.loads(path.read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return ""
|
||||
entry = parsed.get("fireworks") if isinstance(parsed, dict) else None
|
||||
if not isinstance(entry, dict):
|
||||
return ""
|
||||
if entry.get("type") not in (None, "api_key"):
|
||||
return ""
|
||||
return resolve_pi_key(entry.get("key"))
|
||||
|
||||
|
||||
def read_opencode_key(path: Path) -> str:
|
||||
try:
|
||||
parsed = json.loads(path.read_text())
|
||||
@@ -273,12 +227,11 @@ def read_config() -> dict[str, Any]:
|
||||
|
||||
def credentials(auth_path: Path, config: dict[str, Any]) -> tuple[str, str]:
|
||||
file_key, file_account = read_auth_file(auth_path)
|
||||
# Agent logins are the fallback ladder below an explicit key or a firectl
|
||||
# login: pi first as Omarchy's default agent, opencode as the last resort.
|
||||
# opencode is the last resort: an explicit key or a firectl login should
|
||||
# win over whatever another tool happens to be signed in with.
|
||||
api_key = (
|
||||
str(os.environ.get("FIREWORKS_API_KEY", "")).strip()
|
||||
or file_key
|
||||
or read_pi_key(pi_auth_path())
|
||||
or read_opencode_key(opencode_auth_path())
|
||||
)
|
||||
account_id = (
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
# omarchy:summary=Print the Grok usage record as JSON
|
||||
# omarchy:args=[--force] [--limits-only]
|
||||
# omarchy:hidden=true
|
||||
"""Collect Grok usage into one display-ready JSON record.
|
||||
|
||||
The plan comes from the settings Grok caches from xAI, and the limit from the
|
||||
credits endpoint the Grok CLI itself reads for its /usage view, asked with
|
||||
each account's own sign-in. Sessions come from the summary Grok keeps beside
|
||||
each session, and tokens and prompts from its usage.json, the ledger `grok
|
||||
usage` prints: one entry per finished turn, with when it ended and its tokens
|
||||
by model. The agents panel only ever reads the JSON this prints.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
AGENT_ID = "grok"
|
||||
AGENT_NAME = "Grok"
|
||||
AUTH_HELP = "Start Grok, or run `grok login`, to sign in."
|
||||
CREDITS_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits"
|
||||
# Grok's own CLI watches its subscription once a minute; a panel opened and
|
||||
# shut repeatedly reuses an answer for about that long.
|
||||
PROBE_REUSE_SECONDS = 60
|
||||
# Grok's docs give a saved sign-in 30 days; a refresh token older than that
|
||||
# can't renew the access token, so it no longer counts as signed in.
|
||||
REFRESH_LIFETIME_SECONDS = 30 * 24 * 3600
|
||||
# A limits-only refresh needs fresh limits, not a fresh history scan.
|
||||
LIMITS_ONLY_SCAN_REUSE_SECONDS = 900
|
||||
|
||||
|
||||
def read_json(path):
|
||||
try:
|
||||
return json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# A temporary file of its own per write, so overlapping runs can't rename
|
||||
# each other's away.
|
||||
def write_json(path, payload):
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent))
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle)
|
||||
os.replace(tmp, path)
|
||||
except Exception:
|
||||
Path(tmp).unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def cache_root():
|
||||
return Path(os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")) / "omarchy" / "agent-usage"
|
||||
|
||||
|
||||
def parse_time(value):
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(value or "").replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
# The home Grok uses when nothing selects an account: GROK_HOME when set,
|
||||
# as the CLI itself reads it, else ~/.grok.
|
||||
def default_home():
|
||||
return Path(os.environ.get("GROK_HOME") or (Path.home() / ".grok"))
|
||||
|
||||
|
||||
# Every session under <home>/sessions/<folder>/<session>/: its summary says
|
||||
# when it was last active and how many prompts it had, and its usage.json
|
||||
# lists each finished turn with its tokens by model. Added accounts link
|
||||
# their sessions to the primary's, so this covers all of them. Grok counts
|
||||
# cached input inside inputTokens; the record keeps the two apart, as the
|
||||
# other collectors do.
|
||||
def local_stats(home):
|
||||
now = datetime.now().astimezone()
|
||||
today = now.date().isoformat()
|
||||
week = [(now.date() - timedelta(days=offset)).isoformat() for offset in range(6, -1, -1)]
|
||||
prompts = sessions = today_sessions = today_prompts = today_tokens = 0
|
||||
days = set()
|
||||
by_day = {day: 0 for day in week}
|
||||
today_by_model = {}
|
||||
models = {}
|
||||
for session in (Path(home) / "sessions").glob("*/*/"):
|
||||
summary = read_json(session / "summary.json")
|
||||
if isinstance(summary, dict):
|
||||
active = parse_time(summary.get("last_active_at"))
|
||||
if active:
|
||||
day = active.astimezone().date().isoformat()
|
||||
sessions += 1
|
||||
prompts += int(summary.get("num_messages") or 0)
|
||||
days.add(day)
|
||||
if day == today:
|
||||
today_sessions += 1
|
||||
|
||||
usage = read_json(session / "usage.json")
|
||||
turns = usage.get("turns") if isinstance(usage, dict) else None
|
||||
for turn in turns if isinstance(turns, list) else []:
|
||||
ended = parse_time(turn.get("endedAt")) if isinstance(turn, dict) else None
|
||||
if not ended:
|
||||
continue
|
||||
day = ended.astimezone().date().isoformat()
|
||||
days.add(day)
|
||||
if day == today:
|
||||
today_prompts += 1
|
||||
for model, bucket in (turn.get("modelUsage") or {}).items():
|
||||
if not isinstance(bucket, dict):
|
||||
continue
|
||||
# Grok's totalTokens leaves cache writes out; counting them here keeps
|
||||
# the day's total equal to the sum of its model's buckets.
|
||||
cached = int(bucket.get("cachedReadTokens") or 0)
|
||||
uncached = max(0, int(bucket.get("inputTokens") or 0) - cached)
|
||||
output = int(bucket.get("outputTokens") or 0)
|
||||
created = int(bucket.get("cacheCreationTokens") or 0)
|
||||
tokens = uncached + cached + output + created
|
||||
totals = models.setdefault(model, {"inputTokens": 0, "outputTokens": 0, "cacheReadInputTokens": 0, "cacheCreationInputTokens": 0})
|
||||
totals["inputTokens"] += uncached
|
||||
totals["outputTokens"] += output
|
||||
totals["cacheReadInputTokens"] += cached
|
||||
totals["cacheCreationInputTokens"] += created
|
||||
if day in by_day:
|
||||
by_day[day] += tokens
|
||||
if day == today:
|
||||
today_tokens += tokens
|
||||
today_by_model[model] = today_by_model.get(model, 0) + tokens
|
||||
|
||||
return {
|
||||
"hasLocalStats": sessions > 0,
|
||||
"todayPrompts": today_prompts,
|
||||
"todaySessions": today_sessions,
|
||||
"todayTotalTokens": today_tokens,
|
||||
"todayTokensByModel": today_by_model,
|
||||
"recentDays": [{"date": day, "messageCount": by_day[day]} for day in week],
|
||||
"totalPrompts": prompts,
|
||||
"totalSessions": sessions,
|
||||
"activeDays": len(days),
|
||||
"activeDates": sorted(days),
|
||||
"modelUsage": models,
|
||||
}
|
||||
|
||||
|
||||
# A scan from before midnight would count yesterday's sessions as today's.
|
||||
def cached_local_stats(home, max_age):
|
||||
cache = cache_root() / "grok-stats.json"
|
||||
cached = read_json(cache) or {}
|
||||
today = datetime.now().astimezone().date().isoformat()
|
||||
if cached.get("home") == str(home) and cached.get("day") == today and time.time() - float(cached.get("at") or 0) < max_age:
|
||||
return cached["stats"]
|
||||
stats = local_stats(home)
|
||||
write_json(cache, {"home": str(home), "day": today, "at": time.time(), "stats": stats})
|
||||
return stats
|
||||
|
||||
|
||||
# One login per issuer in auth.json; the first is the CLI's own.
|
||||
def login(home):
|
||||
auth = read_json(Path(home) / "auth.json")
|
||||
if not isinstance(auth, dict):
|
||||
return {}
|
||||
return next((v for v in auth.values() if isinstance(v, dict)), {})
|
||||
|
||||
|
||||
def plan(home):
|
||||
cached = read_json(Path(home) / "settings_cache.json") or {}
|
||||
try:
|
||||
settings = json.loads(cached.get("payload") or "{}").get("settings") or {}
|
||||
except Exception:
|
||||
return ""
|
||||
return str(settings.get("subscription_tier_display") or "")
|
||||
|
||||
|
||||
# The credits answer as one limit window: how much of this period's included
|
||||
# usage is gone, and when the period ends. The answer is protobuf JSON, which
|
||||
# leaves out a field holding zero, so a period with nothing used yet has no
|
||||
# percentage at all.
|
||||
def credits_limit(data):
|
||||
config = data["config"]
|
||||
period = config["currentPeriod"]
|
||||
return {
|
||||
"label": "Weekly" if "WEEK" in str(period.get("type")) else "Monthly",
|
||||
"percent": max(0.0, min(1.0, float(config.get("creditUsagePercent") or 0) / 100)),
|
||||
"resetsAt": parse_time(period["end"]).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# A window whose reset has passed starts over empty. A weekly one's next
|
||||
# reset is a whole number of weeks later; a monthly one's isn't known.
|
||||
def rolled_forward(limits):
|
||||
now = datetime.now(timezone.utc)
|
||||
rolled = []
|
||||
for limit in limits:
|
||||
resets = parse_time(limit.get("resetsAt"))
|
||||
if resets and resets <= now:
|
||||
limit = dict(limit, percent=0.0, resetsAt="")
|
||||
if limit.get("label") == "Weekly":
|
||||
weeks = (now - resets) // timedelta(weeks=1) + 1
|
||||
limit["resetsAt"] = (resets + weeks * timedelta(weeks=1)).isoformat()
|
||||
rolled.append(limit)
|
||||
return rolled
|
||||
|
||||
|
||||
def collect_limits(home, key, force):
|
||||
result = {"limits": [], "usageStatusText": "", "authHelpText": AUTH_HELP, "live": False, "fetchedAtMs": 0}
|
||||
entry = login(home)
|
||||
token = str(entry.get("key") or "")
|
||||
if not token:
|
||||
result["usageStatusText"] = "Waiting for auth"
|
||||
return result
|
||||
|
||||
cache = cache_root() / f"grok-limits-{key}.json"
|
||||
cached = read_json(cache) or {}
|
||||
fetched_at = float(cached.get("fetchedAtMs") or 0)
|
||||
result["fetchedAtMs"] = fetched_at
|
||||
|
||||
expires = parse_time(entry.get("expires_at"))
|
||||
if expires and expires.timestamp() <= time.time():
|
||||
result["limits"] = cached.get("limits") or []
|
||||
# The access token lives six hours and Grok mints a new one from its
|
||||
# refresh token whenever it starts, so a lapsed one is routine, not a
|
||||
# sign-out. And while Grok hasn't run, nothing here has spent its
|
||||
# allowance, so the last numbers still stand, rolled past any reset.
|
||||
if entry.get("refresh_token") and time.time() - expires.timestamp() < REFRESH_LIFETIME_SECONDS:
|
||||
result.update(limits=rolled_forward(result["limits"]), live=True)
|
||||
if not result["limits"]:
|
||||
result["usageStatusText"] = "Limits paused"
|
||||
result["authHelpText"] = "Start Grok to see its limits; signing in again is not needed."
|
||||
else:
|
||||
result["usageStatusText"] = "Sign-in expired"
|
||||
result["authHelpText"] = "Grok's saved sign-in expired. Start Grok to refresh it."
|
||||
return result
|
||||
|
||||
if cached.get("limits") and not force and time.time() - fetched_at / 1000 < PROBE_REUSE_SECONDS:
|
||||
result.update(limits=cached["limits"], live=True, authHelpText="")
|
||||
return result
|
||||
|
||||
request = urllib.request.Request(CREDITS_URL, headers={"Authorization": f"Bearer {token}", "Accept": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=8) as response:
|
||||
limit = credits_limit(json.loads(response.read()))
|
||||
except urllib.error.HTTPError as error:
|
||||
limit = None
|
||||
if error.code in (401, 403):
|
||||
result["usageStatusText"] = "Waiting for auth"
|
||||
except Exception:
|
||||
limit = None
|
||||
|
||||
if limit:
|
||||
result.update(limits=[limit], live=True, fetchedAtMs=round(time.time() * 1000), authHelpText="")
|
||||
write_json(cache, {"fetchedAtMs": result["fetchedAtMs"], "limits": result["limits"]})
|
||||
else:
|
||||
result["limits"] = cached.get("limits") or []
|
||||
if not result["usageStatusText"] and not result["limits"]:
|
||||
result["usageStatusText"] = "Grok limits unavailable"
|
||||
return result
|
||||
|
||||
|
||||
# The accounts `omarchy agent account` registered, read straight from its
|
||||
# registry. Only a registry holding a second account matters: with one, the
|
||||
# record describes ~/.grok alone.
|
||||
def registered_accounts():
|
||||
state = Path(os.environ.get("XDG_STATE_HOME") or (Path.home() / ".local" / "state"))
|
||||
registry = read_json(state / "omarchy" / "agents" / "accounts" / "grok.json") or {}
|
||||
accounts = [a for a in registry.get("accounts") or [] if isinstance(a, dict) and a.get("id")]
|
||||
if len(accounts) < 2:
|
||||
return []
|
||||
active_id = registry.get("active") or accounts[0]["id"]
|
||||
if not any(a["id"] == active_id for a in accounts):
|
||||
active_id = accounts[0]["id"]
|
||||
for account in accounts:
|
||||
account["active"] = account["id"] == active_id
|
||||
account["switch"] = {
|
||||
"mode": "auto" if registry.get("switch") == "auto" else "manual",
|
||||
"threshold": registry.get("threshold") or 95,
|
||||
}
|
||||
return accounts
|
||||
|
||||
|
||||
def home_of(account):
|
||||
return Path(account.get("home") or (Path.home() / ".grok"))
|
||||
|
||||
|
||||
# Keyed by who the home is signed in as, so a home signed in to someone else
|
||||
# never shows the last one's numbers.
|
||||
def cache_key(home, fallback):
|
||||
user = str(login(home).get("user_id") or "")
|
||||
return re.sub(r"[^A-Za-z0-9_-]", "", user) or fallback
|
||||
|
||||
|
||||
def account_limits(account, force):
|
||||
home = home_of(account)
|
||||
limits = collect_limits(home, cache_key(home, account["id"]), force)
|
||||
return {
|
||||
"id": account["id"],
|
||||
"label": str(account.get("label") or account["id"]),
|
||||
"email": str(account.get("email") or ""),
|
||||
"plan": plan(home) or str(account.get("plan") or ""),
|
||||
"active": account["active"],
|
||||
"primary": bool(account.get("primary")),
|
||||
"limits": limits["limits"],
|
||||
"stale": not limits["live"],
|
||||
"fetchedAt": limits["fetchedAtMs"],
|
||||
"usageStatusText": limits["usageStatusText"],
|
||||
"authHelpText": limits["authHelpText"],
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--force", action="store_true")
|
||||
parser.add_argument("--limits-only", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
registered = registered_accounts()
|
||||
accounts = [account_limits(account, args.force) for account in registered]
|
||||
current = next((a for a in accounts if a["active"]), None)
|
||||
if current:
|
||||
tier = current["plan"]
|
||||
limits = current
|
||||
stale, fetched_at = current["stale"], current["fetchedAt"]
|
||||
else:
|
||||
home = default_home()
|
||||
tier = plan(home)
|
||||
limits = collect_limits(home, cache_key(home, "main"), args.force)
|
||||
stale, fetched_at = not limits["live"], limits["fetchedAtMs"]
|
||||
|
||||
# Nobody signed in to Grok here: an empty record, which the panel skips.
|
||||
# With accounts registered, the primary is ~/.grok by definition.
|
||||
stats_home = Path.home() / ".grok" if accounts else default_home()
|
||||
signed_in = bool(accounts) or bool(login(stats_home))
|
||||
record = {
|
||||
"schemaVersion": 1,
|
||||
"id": AGENT_ID,
|
||||
"name": AGENT_NAME,
|
||||
"updatedAt": datetime.now(timezone.utc).isoformat(),
|
||||
"ready": signed_in,
|
||||
"tierLabel": tier,
|
||||
"usageStatusText": limits["usageStatusText"],
|
||||
"authHelpText": limits["authHelpText"],
|
||||
"limits": limits["limits"],
|
||||
"limitsStale": stale,
|
||||
"limitsFetchedAt": fetched_at,
|
||||
}
|
||||
record.update(cached_local_stats(stats_home, LIMITS_ONLY_SCAN_REUSE_SECONDS if args.limits_only and not args.force else 0))
|
||||
if accounts:
|
||||
record["accountSwitch"] = registered[0]["switch"]
|
||||
record["accounts"] = accounts
|
||||
print(json.dumps(record, separators=(",", ":")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -51,13 +51,6 @@ collect() {
|
||||
mv "$tmp" "$USAGE_DIR/$agent.json"
|
||||
}
|
||||
|
||||
# Accounts can be signed in to someone else since they were added; the
|
||||
# records name them from the registry, so bring it up to date first.
|
||||
accounts_dir="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/agents/accounts"
|
||||
if [[ -f $accounts_dir/claude.json || -f $accounts_dir/codex.json || -f $accounts_dir/grok.json ]]; then
|
||||
"$OMARCHY_PATH/bin/omarchy-agent-account-state" refresh
|
||||
fi
|
||||
|
||||
pids=()
|
||||
for collector in "$OMARCHY_PATH"/bin/omarchy-agent-usage-*; do
|
||||
[[ -x $collector ]] || continue
|
||||
@@ -72,18 +65,4 @@ status=0
|
||||
for pid in "${pids[@]}"; do
|
||||
wait "$pid" || status=1
|
||||
done
|
||||
|
||||
# Fresh limits are when switching accounts near a limit gets decided. A switch
|
||||
# changes which account the record's own limits describe, so that record is
|
||||
# collected once more; the limits probes it repeats are still cached.
|
||||
for agent in claude codex grok; do
|
||||
if wanted "$agent" && [[ -f $accounts_dir/$agent.json ]]; then
|
||||
switched=$("$OMARCHY_PATH/bin/omarchy-agent-account-state" autoswitch "$agent")
|
||||
if [[ -n $switched ]]; then
|
||||
flags=(--limits-only)
|
||||
collect "$OMARCHY_PATH/bin/omarchy-agent-usage-$agent" "$agent" || status=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
exit $status
|
||||
@@ -19,9 +19,6 @@ for diagnostics or after hardware/package updates.
|
||||
|
||||
--defer-provisioning runs without an install user (deferred-provisioning installs create the user at
|
||||
first boot); group grants are recorded in /var/lib/omarchy/provisioning/groups.
|
||||
|
||||
In an image build (/var/lib/omarchy/image/target exists) nothing runs here: every step is queued
|
||||
for omarchy-provision-hardware on the machine's first boot.
|
||||
USAGE
|
||||
}
|
||||
|
||||
@@ -69,21 +66,10 @@ else
|
||||
fi
|
||||
|
||||
export OMARCHY_INSTALL_USER="$install_user"
|
||||
# Only omarchy-provision-hardware runs steps as an image's first boot.
|
||||
unset OMARCHY_IMAGE_DEFERRED_HARDWARE
|
||||
export OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}"
|
||||
export OMARCHY_INSTALL="${OMARCHY_INSTALL:-$OMARCHY_PATH/install}"
|
||||
export OMARCHY_INSTALL_LOG_FILE="${OMARCHY_INSTALL_LOG_FILE:-/var/log/omarchy-install.log}"
|
||||
export PATH="$OMARCHY_PATH/bin:$PATH"
|
||||
|
||||
source "$OMARCHY_INSTALL/helpers/logging.sh"
|
||||
source "$OMARCHY_INSTALL/helpers/image-target.sh"
|
||||
|
||||
omarchy_image_init
|
||||
omarchy_image_lock
|
||||
if omarchy_image_manifest_present; then
|
||||
omarchy_image_read_manifest
|
||||
omarchy_image_defer_hardware
|
||||
else
|
||||
source "$OMARCHY_INSTALL/hardware/all.sh"
|
||||
fi
|
||||
source "$OMARCHY_INSTALL/hardware/all.sh"
|
||||
+2
-34
@@ -6,12 +6,6 @@
|
||||
|
||||
set -e
|
||||
|
||||
# Install and upgrade callers can start this helper as root. Ignore their PATH
|
||||
# so optional commands never fall through to a user-writable directory.
|
||||
if (( EUID == 0 )); then
|
||||
export PATH=/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin
|
||||
fi
|
||||
|
||||
target_user=${OMARCHY_INSTALL_USER:-${SUDO_USER:-}}
|
||||
if [[ -z $target_user && -n ${PKEXEC_UID:-} ]]; then
|
||||
target_user=$(getent passwd "$PKEXEC_UID" | cut -d: -f1)
|
||||
@@ -40,41 +34,15 @@ auth required pam_faillock.so authsucc
|
||||
account include system-local-login
|
||||
EOF
|
||||
|
||||
# Install resume recovery alongside fingerprint PAM, so setup and upgrades
|
||||
# cannot leave one configured without the other.
|
||||
resume_hook_src="$OMARCHY_PATH/default/systemd/system-sleep/fprintd-resume"
|
||||
resume_hook_dst=/usr/lib/systemd/system-sleep/fprintd-resume
|
||||
stop_timeout_src="$OMARCHY_PATH/default/systemd/system/fprintd.service.d/10-stop-timeout.conf"
|
||||
stop_timeout_dst=/etc/systemd/system/fprintd.service.d/10-stop-timeout.conf
|
||||
|
||||
# Keep the privileged probe pinned to /usr/bin; failure is not empty enrollment.
|
||||
if [[ -x /usr/bin/fprintd-list ]]; then
|
||||
fingerprint_status=$(/usr/bin/fprintd-list "$target_user" 2>&1 || true)
|
||||
else
|
||||
fingerprint_status="no"
|
||||
fi
|
||||
|
||||
if grep -qE '^[[:space:]]*-[[:space:]]*#[0-9]+:' <<< "$fingerprint_status"; then
|
||||
if omarchy-cmd-present fprintd-list && fprintd-list "$target_user" 2>/dev/null | grep -qi finger; then
|
||||
echo "Configuring lock screen fingerprint authentication..."
|
||||
as_root install -Dm755 -o root -g root "$resume_hook_src" "$resume_hook_dst"
|
||||
as_root install -Dm644 -o root -g root "$stop_timeout_src" "$stop_timeout_dst"
|
||||
as_root systemctl daemon-reload
|
||||
as_root tee /etc/pam.d/omarchy-lock-fingerprint >/dev/null <<'EOF'
|
||||
#%PAM-1.0
|
||||
auth required pam_fprintd.so
|
||||
account include system-local-login
|
||||
EOF
|
||||
elif [[ $fingerprint_status == "no" ]] || grep -qi 'has no fingers enrolled' <<< "$fingerprint_status"; then
|
||||
as_root rm -f /etc/pam.d/omarchy-lock-fingerprint "$resume_hook_dst"
|
||||
# Only reload when something was there: this branch also runs in the
|
||||
# install chroot, where there is no systemd to reload.
|
||||
if [[ -e $stop_timeout_dst ]]; then
|
||||
as_root rm -f "$stop_timeout_dst"
|
||||
as_root rmdir --ignore-fail-on-non-empty "$(dirname "$stop_timeout_dst")"
|
||||
as_root systemctl daemon-reload
|
||||
fi
|
||||
else
|
||||
echo "Could not check fingerprint enrollment; keeping the existing configuration." >&2
|
||||
as_root rm -f /etc/pam.d/omarchy-lock-fingerprint
|
||||
fi
|
||||
|
||||
# omarchy-shell can't reach a running shell during chroot install. The echo
|
||||
|
||||
@@ -15,14 +15,6 @@ fi
|
||||
wpctl set-default "$node_id" 2>/dev/null || true
|
||||
pactl set-default-source "$source_name" 2>/dev/null || true
|
||||
|
||||
# A filter's own capture (the input half of a filter chain, loopback or echo
|
||||
# canceller, which PipeWire joins to its output by node.link-group) is the
|
||||
# processing, not an app: moving it would feed the filter from the new input, or
|
||||
# from its own output. pactl translates the listing, so it is read in the C
|
||||
# locale.
|
||||
LC_ALL=C pactl list source-outputs 2>/dev/null | awk '
|
||||
/^Source Output #/ { if (id != "" && !filter) print id; id = substr($3, 2); filter = 0 }
|
||||
/^[[:space:]]*node\.link-group = / { filter = 1 }
|
||||
END { if (id != "" && !filter) print id }' | while read -r output; do
|
||||
pactl list short source-outputs 2>/dev/null | awk '{ print $1 }' | while read -r output; do
|
||||
[[ -n $output ]] && pactl move-source-output "$output" "$source_name" 2>/dev/null || true
|
||||
done
|
||||
@@ -29,25 +29,16 @@ fi
|
||||
# stream down to the sink underneath.
|
||||
downstream="$(pactl list sink-inputs 2>/dev/null |
|
||||
awk -v virt="$sink" '
|
||||
BEGIN {
|
||||
output = virt
|
||||
sub(/^effect_input\./, "effect_output.", output)
|
||||
}
|
||||
/^Sink Input #/ {target = ""}
|
||||
/^[[:space:]]*Sink:/ {target = $2}
|
||||
/node\.name = / {
|
||||
name = $0
|
||||
sub(/.*node\.name = "/, "", name)
|
||||
sub(/"$/, "", name)
|
||||
if (target != "" && (name == output || (output == virt && index(name, virt) == 1))) {selected = target; exit}
|
||||
if (target != "" && fallback == "" && index(name, virt) == 1) fallback = target
|
||||
if (index(name, virt) == 1 && target != "") {print target; exit}
|
||||
}
|
||||
/application\.name = "EasyEffects"/ {
|
||||
if (virt == "easyeffects_sink" && target != "") {selected = target; exit}
|
||||
}
|
||||
END {
|
||||
if (selected != "") print selected
|
||||
else if (fallback != "") print fallback
|
||||
if (virt == "easyeffects_sink" && target != "") {print target; exit}
|
||||
}')"
|
||||
|
||||
if [[ -n $downstream ]]; then
|
||||
@@ -59,38 +50,6 @@ if [[ -n $downstream ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# EasyEffects 8 does not play into the physical sink through a stream of its
|
||||
# own: its output node links its ports straight to the sink's playback ports,
|
||||
# so the sink-input search above finds nothing and the keys would move the
|
||||
# EasyEffects sink -- whose volume its monitor, the input of the processing,
|
||||
# does not follow. Follow the port links instead: the first ALSA or Bluetooth
|
||||
# sink any EasyEffects output port is linked to.
|
||||
if [[ $sink == "easyeffects_sink" ]]; then
|
||||
linked="$(pw-link -l 2>/dev/null |
|
||||
awk '
|
||||
/^[^ ]/ {src = $0}
|
||||
/^[[:space:]]*\|->/ {
|
||||
if (src ~ /^ee_/ && $2 ~ /^(alsa|bluez)_output\..*:playback_/) {
|
||||
sub(/:playback_.*$/, "", $2); print $2; exit
|
||||
}
|
||||
}')"
|
||||
|
||||
# Idle, EasyEffects drops those links: fall back to the output device it is
|
||||
# configured to feed, when that sink exists.
|
||||
if [[ -z $linked ]]; then
|
||||
configured="$(sed -n 's/^outputDevice=//p' \
|
||||
"${XDG_CONFIG_HOME:-$HOME/.config}/easyeffects/db/easyeffectsrc" 2>/dev/null)"
|
||||
if [[ -n $configured ]] && pactl list sinks short 2>/dev/null | awk '{print $2}' | grep -qxF -- "$configured"; then
|
||||
linked="$configured"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n $linked ]]; then
|
||||
printf '%s\n' "$linked"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Nothing resolvable downstream -- the DSP sink may simply be idle and unlinked.
|
||||
# Fall back to the sink itself so callers still have something to act on.
|
||||
printf '%s\n' "$sink"
|
||||
@@ -1,27 +1,14 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Print PulseAudio sink or source availability for the shell
|
||||
# omarchy:summary=Print PulseAudio sink availability for the shell
|
||||
# omarchy:group=audio
|
||||
# omarchy:args=[sources]
|
||||
|
||||
# With "sources", print source availability instead: a jack input such as a
|
||||
# headset microphone reports its port unavailable while nothing is plugged in.
|
||||
# pactl translates the listing ("not available" too), so it is read in the C
|
||||
# locale.
|
||||
kind=sinks
|
||||
header="Sink #"
|
||||
if [[ ${1:-} == "sources" ]]; then
|
||||
kind=sources
|
||||
header="Source #"
|
||||
fi
|
||||
|
||||
# A speaker tuning is a virtual sink in front of the real speakers. Both exist
|
||||
# in the graph, but selecting the physical one would only bypass the tuning, so
|
||||
# report it unavailable and keep it out of the output list.
|
||||
fronted=""
|
||||
[[ $kind == sinks ]] && fronted="$(omarchy-audio-tuning fronted-sink 2>/dev/null || true)"
|
||||
fronted="$(omarchy-audio-tuning fronted-sink 2>/dev/null || true)"
|
||||
|
||||
LC_ALL=C pactl list "$kind" 2>/dev/null | awk -v fronted="$fronted" -v header="$header" '
|
||||
pactl list sinks 2>/dev/null | awk -v fronted="$fronted" '
|
||||
function emit_sink() {
|
||||
if (name == "") return
|
||||
if (fronted != "" && name == fronted) {
|
||||
@@ -31,7 +18,7 @@ LC_ALL=C pactl list "$kind" 2>/dev/null | awk -v fronted="$fronted" -v header="$
|
||||
print name "\t" ((port_count == 0 || available) ? 1 : 0)
|
||||
}
|
||||
|
||||
index($0, header) == 1 {
|
||||
/^Sink #/ {
|
||||
emit_sink()
|
||||
name = ""
|
||||
in_ports = 0
|
||||
|
||||
@@ -109,10 +109,7 @@ right)
|
||||
;;
|
||||
esac
|
||||
|
||||
# Sample the first frame only. Without the selector a video background makes
|
||||
# ImageMagick decode the whole file and emit one value per frame, and the match
|
||||
# below then fails into the fallback colour.
|
||||
pixel=$(magick "$background_path[0]" -auto-orient \
|
||||
pixel=$(magick "$background_path" -auto-orient \
|
||||
-resize "${screen_width}x${screen_height}^" \
|
||||
-gravity center -extent "${screen_width}x${screen_height}" \
|
||||
-gravity NorthWest -crop "$crop" +repage \
|
||||
|
||||
@@ -2,16 +2,10 @@
|
||||
|
||||
# omarchy:summary=Returns true if a battery is present on the system.
|
||||
|
||||
# Not every battery is called BAT*: Apple Silicon reports macsmc-battery and
|
||||
# some Framework and Chromebook firmware CMB0, so decide by type, not by name.
|
||||
power_supply_path="${OMARCHY_POWER_SUPPLY_PATH:-/sys/class/power_supply}"
|
||||
|
||||
for bat in "$power_supply_path"/*; do
|
||||
[[ -r $bat/present && $(<"$bat/present") == "1" ]] || continue
|
||||
[[ -r $bat/type && $(<"$bat/type") == "Battery" ]] || continue
|
||||
# A wireless mouse or keyboard registers a Battery too, scoped Device. The
|
||||
# one that powers the machine is scoped System, or unscoped on older drivers.
|
||||
[[ -r $bat/scope && $(<"$bat/scope") == "Device" ]] && continue
|
||||
for bat in /sys/class/power_supply/BAT*; do
|
||||
[[ -r $bat/present ]] &&
|
||||
[[ $(cat $bat/present) == "1" ]] &&
|
||||
[[ $(cat $bat/type) == "Battery" ]] &&
|
||||
exit 0
|
||||
done
|
||||
|
||||
|
||||
@@ -18,23 +18,11 @@ case "${1:-}" in
|
||||
;;
|
||||
esac
|
||||
|
||||
# UPower names the device after the sysfs entry, and only some kernels call
|
||||
# that BAT*: Apple Silicon reports battery_macsmc_battery. Match on the class,
|
||||
# then take the first one that powers the machine: a wireless mouse or keyboard
|
||||
# is a battery_ device too, and UPower marks it "power supply: no".
|
||||
battery=""
|
||||
battery_info=""
|
||||
while read -r candidate; do
|
||||
[[ -n $candidate ]] || continue
|
||||
candidate_info=$(upower -i "$candidate" 2>/dev/null) || continue
|
||||
if grep -qE '^\s*power supply:\s+yes' <<<"$candidate_info"; then
|
||||
battery=$candidate
|
||||
battery_info=$candidate_info
|
||||
break
|
||||
fi
|
||||
done < <(upower -e 2>/dev/null | grep '/battery_')
|
||||
battery=$(upower -e 2>/dev/null | grep BAT | head -n 1)
|
||||
[[ -z $battery ]] && exit 0
|
||||
|
||||
battery_info=$(upower -i "$battery")
|
||||
|
||||
percentage=$(awk '/percentage/ { print int($2); exit }' <<<"$battery_info")
|
||||
capacity=$(awk '/energy-full:/ { printf "%d", $2; exit }' <<<"$battery_info")
|
||||
time_remaining=$(awk '/time to (empty|full)/ {
|
||||
@@ -68,10 +56,7 @@ elif [[ -r $battery_path/current_now && -r $battery_path/voltage_now ]]; then
|
||||
'BEGIN { print microamps * microvolts / 1000000000000 }')
|
||||
fi
|
||||
|
||||
# Some kernels sign power_now by direction (negative while discharging on
|
||||
# Apple Silicon); the state field already says which way it flows.
|
||||
power_rate=$(awk -v rate="${power_rate_raw:-0}" 'BEGIN {
|
||||
if (rate < 0) rate = -rate
|
||||
rounded = sprintf("%.1f", rate)
|
||||
sub(/\.0$/, "", rounded)
|
||||
print rounded
|
||||
@@ -80,12 +65,8 @@ state=$(awk '/state/ { print $2; exit }' <<<"$battery_info")
|
||||
threshold_start=$(awk '/charge-start-threshold:/ { gsub(/%/, "", $2); print int($2); exit }' <<<"$battery_info")
|
||||
threshold_end=$(awk '/charge-end-threshold:/ { gsub(/%/, "", $2); print int($2); exit }' <<<"$battery_info")
|
||||
|
||||
# Read the thresholds from the battery UPower resolved rather than a BAT* glob,
|
||||
# which misses every battery with another name.
|
||||
[[ -n $threshold_end || ! -r $battery_path/charge_control_end_threshold ]] || threshold_end=$(<"$battery_path/charge_control_end_threshold")
|
||||
[[ -n $threshold_start || ! -r $battery_path/charge_control_start_threshold ]] || threshold_start=$(<"$battery_path/charge_control_start_threshold")
|
||||
[[ -n $threshold_end ]] || threshold_end=$(cat "$power_supply_path"/BAT*/charge_control_end_threshold 2>/dev/null | head -1)
|
||||
[[ -n $threshold_start ]] || threshold_start=$(cat "$power_supply_path"/BAT*/charge_control_start_threshold 2>/dev/null | head -1)
|
||||
[[ -z $threshold_end ]] && threshold_end=$(cat "$power_supply_path"/BAT*/charge_control_end_threshold 2>/dev/null | head -1)
|
||||
[[ -z $threshold_start ]] && threshold_start=$(cat "$power_supply_path"/BAT*/charge_control_start_threshold 2>/dev/null | head -1)
|
||||
|
||||
ac_online=false
|
||||
for supply in "$power_supply_path"/*; do
|
||||
@@ -126,9 +107,7 @@ if [[ $shell_output == "true" ]]; then
|
||||
printf 'size\t%s\n' "${capacity}Wh"
|
||||
printf 'time\t%s\n' "$time_remaining"
|
||||
|
||||
cycles=""
|
||||
[[ ! -r $battery_path/cycle_count ]] || cycles=$(<"$battery_path/cycle_count")
|
||||
[[ -n $cycles ]] || cycles=$(cat "$power_supply_path"/BAT*/cycle_count 2>/dev/null | head -1)
|
||||
cycles=$(cat "$power_supply_path"/BAT*/cycle_count 2>/dev/null | head -1)
|
||||
|
||||
[[ -n $cycles ]] && printf 'cycles\t%s\n' "$cycles"
|
||||
|
||||
|
||||
@@ -40,30 +40,7 @@ use_apple_display() {
|
||||
}
|
||||
|
||||
use_ddc_display() {
|
||||
[[ -n $monitor ]] && ! monitor_is_internal && monitor_may_have_ddc
|
||||
}
|
||||
|
||||
# A platform whose display driver registers no DDC channel says so in its
|
||||
# displays.conf (see docs/file-layout.md): there a probe finds only unrelated
|
||||
# I2C buses, slowly, so only a connector with a ddc node is probed. Everywhere
|
||||
# else every external monitor is.
|
||||
monitor_may_have_ddc() {
|
||||
local displays_conf=/usr/share/omarchy-platform/displays.conf directive argument
|
||||
[[ -r $displays_conf ]] || return 0
|
||||
|
||||
while IFS=$' \t\r' read -r directive argument || [[ -n $directive ]]; do
|
||||
if [[ $directive == "ddc-require-connector-ddc" && -z $argument ]]; then
|
||||
compgen -G "/sys/class/drm/card*-$monitor/ddc" >/dev/null
|
||||
return
|
||||
fi
|
||||
done <"$displays_conf"
|
||||
return 0
|
||||
}
|
||||
|
||||
# The kernel backlight is the built-in panel's, so an external monitor without
|
||||
# DDC fails rather than dimming the panel.
|
||||
use_backlight_display() {
|
||||
[[ -z $monitor ]] || monitor_is_internal
|
||||
[[ -n $monitor ]] && ! monitor_is_internal
|
||||
}
|
||||
|
||||
if (( $# == 0 )); then
|
||||
@@ -73,8 +50,6 @@ if (( $# == 0 )); then
|
||||
elif use_ddc_display; then
|
||||
omarchy-brightness-display-ddc "$monitor"
|
||||
exit
|
||||
elif ! use_backlight_display; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
device="$(omarchy-hw-display)" || exit 1
|
||||
@@ -110,8 +85,6 @@ if use_apple_display; then
|
||||
elif use_ddc_display; then
|
||||
brightness="$(omarchy-brightness-display-ddc "$monitor" "$step")" || exit 1
|
||||
(( no_osd )) || omarchy-osd -i brightness -p "$brightness"
|
||||
elif ! use_backlight_display; then
|
||||
exit 1
|
||||
else
|
||||
# Current device highlighted
|
||||
device="$(omarchy-hw-display)" || exit 1
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# omarchy:summary=Start or stop screen recording
|
||||
# omarchy:group=capture
|
||||
# omarchy:args=[--fullscreen] [--with-desktop-audio] [--with-microphone-audio] [--with-webcam] [--webcam-device=<device>] [--webcam-size=<small|medium|large>] [--resolution=<size>] [--stop-recording] [--status]
|
||||
# omarchy:args=[--fullscreen] [--with-desktop-audio] [--with-microphone-audio] [--with-webcam] [--webcam-device=<device>] [--webcam-size=<small|medium|large>] [--resolution=<size>] [--stop-recording]
|
||||
# omarchy:examples=omarchy screenrecord | omarchy capture screenrecording --with-desktop-audio
|
||||
# omarchy:aliases=omarchy screenrecord
|
||||
#
|
||||
@@ -14,20 +14,18 @@
|
||||
# DMA-BUF modifier import on some configurations, leaving recording unable to
|
||||
# start.
|
||||
#
|
||||
# Env: OMARCHY_SCREENRECORD_DEBUG=true appends the recorder's stderr (and the
|
||||
# picker target it was launched with) to $XDG_RUNTIME_DIR/omarchy-screenrecord.log
|
||||
# so users can attach a log when reporting capture failures.
|
||||
#
|
||||
# Backend: gpu-screen-recorder. Where it is not installed, or it exits before
|
||||
# the recording starts (it knows only NVIDIA, AMD, Intel and Broadcom GPUs, and
|
||||
# aborts on others such as Apple Silicon's), wf-recorder records instead when
|
||||
# it is installed. wf-recorder encodes on the CPU and captures one audio
|
||||
# device, so desktop and microphone audio together go through a temporary null
|
||||
# sink. Portal capture and --resolution are gpu-screen-recorder only.
|
||||
# Env: OMARCHY_SCREENRECORD_DEBUG=true appends gpu-screen-recorder's stderr (and
|
||||
# the picker target it was launched with) to /tmp/omarchy-screenrecord.log so
|
||||
# users can attach a log when reporting capture failures.
|
||||
|
||||
[[ -f ~/.config/user-dirs.dirs ]] && source ~/.config/user-dirs.dirs
|
||||
OUTPUT_DIR="${OMARCHY_SCREENRECORD_DIR:-${XDG_VIDEOS_DIR:-$HOME/Videos}}"
|
||||
|
||||
if [[ ! -d $OUTPUT_DIR ]]; then
|
||||
omarchy-notification-send -u critical -t 3000 "Screen recording directory does not exist: $OUTPUT_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DESKTOP_AUDIO="false"
|
||||
MICROPHONE_AUDIO="false"
|
||||
WEBCAM="false"
|
||||
@@ -36,30 +34,9 @@ WEBCAM_SIZE="medium"
|
||||
RESOLUTION=""
|
||||
FULLSCREEN="false"
|
||||
STOP_RECORDING="false"
|
||||
STATUS="false"
|
||||
# Both of these live in the per-user runtime directory, which is 0700. What
|
||||
# RECORDING_FILE holds is read back on stop and used as a path -- ffmpeg writes
|
||||
# beside it, `mv` replaces it, and `rm -f` deletes its preview -- so a name in
|
||||
# world-writable /tmp is a name any other local account can create first and
|
||||
# then point wherever it likes. The debug log carries the recorder's stderr and
|
||||
# the picked geometry, which is nobody else's business either. Fall back to
|
||||
# the state directory when there is no session runtime dir -- :-/tmp would put
|
||||
# both files back at the fixed names this comment describes.
|
||||
RUNTIME_DIR="${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/omarchy}"
|
||||
RECORDING_FILE="$RUNTIME_DIR/omarchy-screenrecord-filename"
|
||||
REGION_FILE="$RUNTIME_DIR/omarchy-screenrecord-region"
|
||||
# The recorder's pid, so stop and status act on that process alone.
|
||||
REC_PID_FILE="$RUNTIME_DIR/omarchy-screenrecord-pid"
|
||||
# The PulseAudio module ids of a combined-audio null sink, for cleanup.
|
||||
PA_MODULES_FILE="$RUNTIME_DIR/omarchy-screenrecord-pa-modules"
|
||||
# Present while a start holds the lock; another toggle or stop then leaves
|
||||
# CANCEL_FILE for that start instead of queueing behind its picker or recorder.
|
||||
STARTING_FILE="$RUNTIME_DIR/omarchy-screenrecord-starting"
|
||||
CANCEL_FILE="$RUNTIME_DIR/omarchy-screenrecord-cancel"
|
||||
# How long a recorder may take to create its file, except through the portal,
|
||||
# whose picker waits on the user.
|
||||
READY_TIMEOUT_TICKS=75
|
||||
LOG_FILE=$([[ ${OMARCHY_SCREENRECORD_DEBUG:-false} == "true" ]] && echo "$RUNTIME_DIR/omarchy-screenrecord.log" || echo "/dev/null")
|
||||
RECORDING_FILE="/tmp/omarchy-screenrecord-filename"
|
||||
REGION_FILE="${XDG_RUNTIME_DIR:-/tmp}/omarchy-screenrecord-region"
|
||||
LOG_FILE=$([[ ${OMARCHY_SCREENRECORD_DEBUG:-false} == "true" ]] && echo "/tmp/omarchy-screenrecord.log" || echo "/dev/null")
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
@@ -71,7 +48,6 @@ for arg in "$@"; do
|
||||
--resolution=*) RESOLUTION="${arg#*=}" ;;
|
||||
--fullscreen) FULLSCREEN="true" ;;
|
||||
--stop-recording) STOP_RECORDING="true" ;;
|
||||
--status) STATUS="true" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -83,13 +59,6 @@ small | medium | large) ;;
|
||||
;;
|
||||
esac
|
||||
|
||||
# --status only answers whether our recording runs, for the bar and the menu, so
|
||||
# it needs no recordings directory and never notifies.
|
||||
if [[ $STATUS != "true" && ! -d $OUTPUT_DIR ]]; then
|
||||
omarchy-notification-send -u critical -t 3000 "Screen recording directory does not exist: $OUTPUT_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
start_webcam_overlay() {
|
||||
cleanup_webcam
|
||||
|
||||
@@ -120,7 +89,7 @@ start_webcam_overlay() {
|
||||
'--vf=lavfi=[crop=ih*8/9:ih]' \
|
||||
--title="WebcamOverlay" --wayland-app-id="WebcamOverlay-$WEBCAM_SIZE" \
|
||||
--no-border --no-audio --no-osc --osd-level=0 \
|
||||
--really-quiet 9>&- &>/dev/null &
|
||||
--really-quiet &>/dev/null &
|
||||
|
||||
# The move has to settle before gpu-screen-recorder starts, or the camera is
|
||||
# recorded sliding into its corner. Waiting for the map is what the blind
|
||||
@@ -174,132 +143,7 @@ select_capture_target() {
|
||||
echo "region:${BASH_REMATCH[3]}x${BASH_REMATCH[4]}+${BASH_REMATCH[1]}+${BASH_REMATCH[2]}"
|
||||
}
|
||||
|
||||
# gpu-screen-recorder first, then wf-recorder, of those installed. The base
|
||||
# packages carry gpu-screen-recorder, but an architecture may build none.
|
||||
recorder_backends() {
|
||||
local backend found=false
|
||||
|
||||
for backend in gpu-screen-recorder wf-recorder; do
|
||||
if omarchy-cmd-present "$backend"; then
|
||||
echo "$backend"
|
||||
found=true
|
||||
fi
|
||||
done
|
||||
|
||||
# Neither installed: fail the way a missing gpu-screen-recorder always has.
|
||||
$found || echo gpu-screen-recorder
|
||||
}
|
||||
|
||||
# wf-recorder records a single audio device. Map the desktop/mic flags onto one:
|
||||
# desktop -> default sink monitor, mic -> default source. When both are requested,
|
||||
# route them through a throwaway null sink so a single track carries both; the
|
||||
# module ids are saved to PA_MODULES_FILE so stop can unload them. A requested
|
||||
# source that can't be recorded is left out with a notification naming it, and
|
||||
# the recording carries whichever source remains.
|
||||
wf_audio_device() {
|
||||
local sink="" source="" m
|
||||
[[ $DESKTOP_AUDIO == "true" ]] && sink=$(pactl get-default-sink 2>/dev/null)
|
||||
[[ $MICROPHONE_AUDIO == "true" ]] && source=$(pactl get-default-source 2>/dev/null)
|
||||
|
||||
if [[ -n $sink && -n $source ]]; then
|
||||
if m=$(pactl load-module module-null-sink sink_name=omarchy_screenrec sink_properties=device.description=omarchy_screenrec 2>/dev/null); then
|
||||
echo "$m" >"$PA_MODULES_FILE"
|
||||
if m=$(pactl load-module module-loopback source="${sink}.monitor" sink=omarchy_screenrec latency_msec=20 2>/dev/null); then
|
||||
echo "$m" >>"$PA_MODULES_FILE"
|
||||
else
|
||||
sink=""
|
||||
fi
|
||||
if m=$(pactl load-module module-loopback source="$source" sink=omarchy_screenrec latency_msec=20 2>/dev/null); then
|
||||
echo "$m" >>"$PA_MODULES_FILE"
|
||||
else
|
||||
source=""
|
||||
fi
|
||||
if [[ -n $sink && -n $source ]]; then
|
||||
echo "omarchy_screenrec.monitor"
|
||||
return
|
||||
fi
|
||||
wf_audio_cleanup
|
||||
else
|
||||
# No device to mix into: desktop audio records alone, as it always has.
|
||||
source=""
|
||||
fi
|
||||
fi
|
||||
|
||||
[[ $DESKTOP_AUDIO == "true" && -z $sink ]] && wf_audio_unavailable desktop
|
||||
[[ $MICROPHONE_AUDIO == "true" && -z $source ]] && wf_audio_unavailable microphone
|
||||
if [[ -n $sink ]]; then
|
||||
echo "${sink}.monitor"
|
||||
elif [[ -n $source ]]; then
|
||||
echo "$source"
|
||||
fi
|
||||
}
|
||||
|
||||
wf_audio_unavailable() {
|
||||
omarchy-notification-send -u critical -t 5000 "Recording without $1 audio" "The $1 audio could not be added to this recording." >/dev/null 2>&1
|
||||
}
|
||||
|
||||
wf_audio_cleanup() {
|
||||
[[ -f $PA_MODULES_FILE ]] || return
|
||||
# Unload in reverse so the loopbacks drop before the null sink they feed.
|
||||
tac "$PA_MODULES_FILE" 2>/dev/null | while read -r m; do
|
||||
[[ -n $m ]] && pactl unload-module "$m" 2>/dev/null
|
||||
done
|
||||
rm -f "$PA_MODULES_FILE"
|
||||
}
|
||||
|
||||
# Launches wf-recorder for the given picker target, backgrounded, with REC_PID set.
|
||||
launch_wf_recorder() {
|
||||
local target=$1 filename=$2
|
||||
local geom_args=()
|
||||
|
||||
case $target in
|
||||
monitor:*)
|
||||
geom_args=(-o "${target#monitor:}")
|
||||
;;
|
||||
region:*)
|
||||
# region:WxH+X+Y (logical coords) -> wf-recorder -g "X,Y WxH" (same space).
|
||||
[[ ${target#region:} =~ ^([0-9]+)x([0-9]+)\+(-?[0-9]+)\+(-?[0-9]+)$ ]] || return 1
|
||||
geom_args=(-g "${BASH_REMATCH[3]},${BASH_REMATCH[4]} ${BASH_REMATCH[1]}x${BASH_REMATCH[2]}")
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
local audio_args=() dev
|
||||
dev=$(wf_audio_device)
|
||||
[[ -n $dev ]] && audio_args=(--audio="$dev")
|
||||
|
||||
# -r 60 caps the framerate: capturing a 120 Hz panel at its native rate makes
|
||||
# needlessly large files. -R 48000 keeps the AAC track at 48 kHz rather than
|
||||
# following a sink that advertises 96 kHz into a file many players, and some
|
||||
# speaker DSPs, play as silence.
|
||||
wf-recorder "${geom_args[@]}" -r 60 -R 48000 "${audio_args[@]}" -f "$filename" 9>&- 2>>"$LOG_FILE" &
|
||||
REC_PID=$!
|
||||
}
|
||||
|
||||
start_cancelled() {
|
||||
[[ -e $CANCEL_FILE ]]
|
||||
}
|
||||
|
||||
# Stops a recorder this start launched: TERM, two seconds to exit, then KILL.
|
||||
end_launched_recorder() {
|
||||
local pid=$1 waited=0 state
|
||||
kill "$pid" 2>/dev/null
|
||||
while (( waited++ < 20 )); do
|
||||
state=$(<"/proc/$pid/stat") 2>/dev/null || break
|
||||
state=${state##*) }
|
||||
[[ ${state:0:1} != "Z" ]] || break
|
||||
sleep 0.1
|
||||
done
|
||||
kill -KILL "$pid" 2>/dev/null
|
||||
wait "$pid" 2>/dev/null
|
||||
}
|
||||
|
||||
start_screenrecording() {
|
||||
# A pid left by a recording that ended without a stop would otherwise hide
|
||||
# this one from the toggle until it has started and saved its own.
|
||||
rm -f "$REC_PID_FILE"
|
||||
local capture_args=()
|
||||
local target
|
||||
|
||||
@@ -315,7 +159,6 @@ start_screenrecording() {
|
||||
capture_args=(-w portal -s "${RESOLUTION:-$(default_resolution)}")
|
||||
else
|
||||
target=$(select_capture_target) || return 1
|
||||
start_cancelled && return 1
|
||||
|
||||
case $target in
|
||||
monitor:*)
|
||||
@@ -329,101 +172,50 @@ start_screenrecording() {
|
||||
fi
|
||||
|
||||
[[ $WEBCAM == "true" ]] && start_webcam_overlay "$target"
|
||||
start_cancelled && return 1
|
||||
|
||||
local filename="$OUTPUT_DIR/screenrecording-$(date +'%Y-%m-%d_%H-%M-%S').mp4"
|
||||
local backend pid=""
|
||||
local audio_devices=""
|
||||
local audio_args=()
|
||||
|
||||
for backend in $(recorder_backends); do
|
||||
[[ $backend == "wf-recorder" && $target == "portal" ]] && continue
|
||||
echo "===== $(date '+%F %T') args: $* backend: $backend target: $target =====" >>"$LOG_FILE"
|
||||
[[ $DESKTOP_AUDIO == "true" ]] && audio_devices+="default_output"
|
||||
|
||||
if [[ $backend == "wf-recorder" ]]; then
|
||||
launch_wf_recorder "$target" "$filename" || continue
|
||||
pid=$REC_PID
|
||||
else
|
||||
local audio_devices=""
|
||||
local audio_args=()
|
||||
if [[ $MICROPHONE_AUDIO == "true" ]]; then
|
||||
# Merge audio tracks into one - separate tracks only play one at a time in most players
|
||||
[[ -n $audio_devices ]] && audio_devices+="|"
|
||||
audio_devices+="default_input"
|
||||
fi
|
||||
|
||||
[[ $DESKTOP_AUDIO == "true" ]] && audio_devices+="default_output"
|
||||
[[ -n $audio_devices ]] && audio_args+=(-a "$audio_devices" -ac aac)
|
||||
|
||||
if [[ $MICROPHONE_AUDIO == "true" ]]; then
|
||||
# Merge audio tracks into one - separate tracks only play one at a time in most players
|
||||
[[ -n $audio_devices ]] && audio_devices+="|"
|
||||
audio_devices+="default_input"
|
||||
fi
|
||||
echo "===== $(date '+%F %T') args: $* target: $target =====" >>"$LOG_FILE"
|
||||
gpu-screen-recorder "${capture_args[@]}" -k auto -f 60 -fm cfr -fallback-cpu-encoding yes -o "$filename" "${audio_args[@]}" 2>>"$LOG_FILE" &
|
||||
local pid=$!
|
||||
|
||||
[[ -n $audio_devices ]] && audio_args+=(-a "$audio_devices" -ac aac)
|
||||
|
||||
gpu-screen-recorder "${capture_args[@]}" -k auto -f 60 -fm cfr -fallback-cpu-encoding yes -o "$filename" "${audio_args[@]}" 9>&- 2>>"$LOG_FILE" &
|
||||
pid=$!
|
||||
fi
|
||||
|
||||
local ticks=0
|
||||
while kill -0 $pid 2>/dev/null && [[ ! -f $filename ]]; do
|
||||
start_cancelled && break
|
||||
if [[ $target != "portal" ]] && (( ++ticks > READY_TIMEOUT_TICKS )); then
|
||||
end_launched_recorder "$pid"
|
||||
wf_audio_cleanup
|
||||
rm -f "$filename"
|
||||
continue 2
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
if start_cancelled; then
|
||||
end_launched_recorder "$pid"
|
||||
wf_audio_cleanup
|
||||
rm -f "$filename"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if kill -0 $pid 2>/dev/null; then
|
||||
echo "$pid" >"$REC_PID_FILE"
|
||||
echo "$filename" >"$RECORDING_FILE"
|
||||
toggle_screenrecording_indicator
|
||||
return 0
|
||||
fi
|
||||
|
||||
wf_audio_cleanup
|
||||
rm -f "$filename"
|
||||
while kill -0 $pid 2>/dev/null && [[ ! -f $filename ]]; do
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
return 1
|
||||
if kill -0 $pid 2>/dev/null; then
|
||||
echo "$filename" >"$RECORDING_FILE"
|
||||
toggle_screenrecording_indicator
|
||||
fi
|
||||
}
|
||||
|
||||
# Selects the recorder this script started, by the pid it saved. A saved pid
|
||||
# that is no longer a recorder is a recording that ended without a stop, so
|
||||
# nothing of ours is recording and no other recorder is touched. Without a
|
||||
# saved pid (a recording started before the pid was saved), every recorder the
|
||||
# user runs counts, as for the bar indicator and the menu.
|
||||
select_recorder() {
|
||||
RECORDER=()
|
||||
[[ -f $REC_PID_FILE ]] || return 0
|
||||
|
||||
local pid
|
||||
pid=$(<"$REC_PID_FILE")
|
||||
[[ $pid =~ ^[0-9]+$ ]] && omarchy-capture-screenrecording-process --pid "$pid" || return 1
|
||||
RECORDER=(--pid "$pid")
|
||||
}
|
||||
|
||||
# Stops the recorder screenrecording_active selected.
|
||||
stop_screenrecording() {
|
||||
omarchy-capture-screenrecording-process "${RECORDER[@]}" --signal INT # SIGINT required to save video properly
|
||||
pkill -SIGINT -f "^gpu-screen-recorder" # SIGINT required to save video properly
|
||||
|
||||
# Wait a maximum of 5 seconds to finish before hard killing
|
||||
local count=0
|
||||
while omarchy-capture-screenrecording-process "${RECORDER[@]}" && ((count < 50)); do
|
||||
while pgrep -f "^gpu-screen-recorder" >/dev/null && ((count < 50)); do
|
||||
sleep 0.1
|
||||
count=$((count + 1))
|
||||
done
|
||||
|
||||
toggle_screenrecording_indicator
|
||||
cleanup_webcam
|
||||
wf_audio_cleanup
|
||||
|
||||
if omarchy-capture-screenrecording-process "${RECORDER[@]}"; then
|
||||
omarchy-capture-screenrecording-process "${RECORDER[@]}" --signal KILL
|
||||
if pgrep -f "^gpu-screen-recorder" >/dev/null; then
|
||||
pkill -9 -f "^gpu-screen-recorder"
|
||||
omarchy-notification-send -u critical -t 5000 "Screen recording error" "Recording process had to be force-killed. Video may be corrupted."
|
||||
else
|
||||
finalize_recording
|
||||
@@ -444,10 +236,10 @@ stop_screenrecording() {
|
||||
(
|
||||
sleep 2
|
||||
rm -f "$preview"
|
||||
) 9>&- &
|
||||
) &
|
||||
fi
|
||||
|
||||
rm -f "$RECORDING_FILE" "$REC_PID_FILE"
|
||||
rm -f "$RECORDING_FILE"
|
||||
}
|
||||
|
||||
toggle_screenrecording_indicator() {
|
||||
@@ -455,14 +247,7 @@ toggle_screenrecording_indicator() {
|
||||
}
|
||||
|
||||
screenrecording_active() {
|
||||
if select_recorder && omarchy-capture-screenrecording-process "${RECORDER[@]}"; then
|
||||
return 0
|
||||
else
|
||||
if [[ -f $REC_PID_FILE ]]; then
|
||||
wf_audio_cleanup
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
pgrep -f "^gpu-screen-recorder" >/dev/null
|
||||
}
|
||||
|
||||
finalize_recording() {
|
||||
@@ -483,10 +268,7 @@ finalize_recording() {
|
||||
# Hard-mute the first 400ms to drop the PipeWire capture-open pop (a near-clipping
|
||||
# transient around 130-200ms that a gentle fade-in can't attenuate enough), then a
|
||||
# 50ms fade avoids a click at the boundary before loudnorm normalizes the rest.
|
||||
#
|
||||
# loudnorm declares 192 kHz output, so without -ar the AAC encoder writes
|
||||
# 96 kHz from a 48 kHz recording.
|
||||
args+=(-ar 48000 -af "volume=enable='lt(t,0.4)':volume=0,afade=t=in:st=0.4:d=0.05,loudnorm=I=-14:TP=-1.5:LRA=11")
|
||||
args+=(-af "volume=enable='lt(t,0.4)':volume=0,afade=t=in:st=0.4:d=0.05,loudnorm=I=-14:TP=-1.5:LRA=11")
|
||||
fi
|
||||
|
||||
local processed="${latest%.mp4}-processed.mp4"
|
||||
@@ -497,66 +279,10 @@ finalize_recording() {
|
||||
fi
|
||||
}
|
||||
|
||||
mkdir -p "$RUNTIME_DIR" || exit 1
|
||||
# XDG_STATE_HOME may be outside a private home; protect existing fallbacks too.
|
||||
if [[ -z ${XDG_RUNTIME_DIR:-} ]]; then
|
||||
chmod 700 "$RUNTIME_DIR" || exit 1
|
||||
if screenrecording_active; then
|
||||
stop_screenrecording
|
||||
elif [[ $STOP_RECORDING == "true" ]]; then
|
||||
exit 1
|
||||
else
|
||||
start_screenrecording || cleanup_webcam
|
||||
fi
|
||||
|
||||
# Serialize changes to the recorder and its files; background children close 9.
|
||||
# A busy status query stays read-only so a picker cannot stall the bar.
|
||||
{
|
||||
if [[ $STATUS == "true" ]]; then
|
||||
if flock -n 9; then
|
||||
screenrecording_active
|
||||
else
|
||||
select_recorder && omarchy-capture-screenrecording-process "${RECORDER[@]}"
|
||||
fi
|
||||
else
|
||||
# A start still in its picker or waiting on its recorder is cancelled, not
|
||||
# queued behind: that toggle would otherwise begin another recording.
|
||||
# The start reads CANCEL_FILE only after it removes STARTING_FILE, so a
|
||||
# request left while STARTING_FILE stands is always seen. One that finds it
|
||||
# gone waits its turn, and a request the start used up by then is done.
|
||||
if ! flock -w 1 9; then
|
||||
touch "$CANCEL_FILE"
|
||||
# A start that was killed leaves its marker: it counts only while that
|
||||
# start still runs.
|
||||
starter=""
|
||||
[[ -r $STARTING_FILE ]] && starter=$(<"$STARTING_FILE")
|
||||
if [[ $starter =~ ^[0-9]+$ ]] && kill -0 "$starter" 2>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
flock 9 || exit 1
|
||||
[[ -e $CANCEL_FILE ]] || exit 0
|
||||
rm -f "$CANCEL_FILE"
|
||||
fi
|
||||
if screenrecording_active; then
|
||||
stop_screenrecording
|
||||
elif [[ $STOP_RECORDING == "true" ]]; then
|
||||
exit 1
|
||||
else
|
||||
rm -f "$CANCEL_FILE"
|
||||
echo $$ >"$STARTING_FILE"
|
||||
if start_screenrecording; then
|
||||
rm -f "$STARTING_FILE"
|
||||
# Cancelled while the recording was being saved as started.
|
||||
if start_cancelled; then
|
||||
end_launched_recorder "$(<"$REC_PID_FILE")"
|
||||
wf_audio_cleanup
|
||||
cleanup_webcam
|
||||
rm -f "$(<"$RECORDING_FILE")" "$REC_PID_FILE" "$RECORDING_FILE"
|
||||
toggle_screenrecording_indicator
|
||||
rm -f "$CANCEL_FILE"
|
||||
fi
|
||||
# A request after that check is left for its requester, who stops this
|
||||
# recording once it has the lock.
|
||||
else
|
||||
rm -f "$STARTING_FILE"
|
||||
wf_audio_cleanup
|
||||
cleanup_webcam
|
||||
rm -f "$CANCEL_FILE"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
} 9>>"$RUNTIME_DIR/omarchy-screenrecord.lock"
|
||||
@@ -1,72 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Check or signal the current user's recording processes
|
||||
# omarchy:args=[--pid <pid>] [--signal INT|KILL]
|
||||
# omarchy:hidden=true
|
||||
|
||||
usage() {
|
||||
echo "Usage: omarchy-capture-screenrecording-process [--pid <pid>]... [--signal INT|KILL]" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
proc_root=${OMARCHY_PROC_ROOT:-/proc}
|
||||
wanted=" "
|
||||
signal=""
|
||||
|
||||
while (($#)); do
|
||||
case $1 in
|
||||
--pid)
|
||||
# Compare textually first: bash arithmetic wraps values past 64 bits.
|
||||
[[ ${2-} =~ ^0*([0-9]+)$ ]] || usage
|
||||
pid=${BASH_REMATCH[1]}
|
||||
((${#pid} <= 10 && 10#$pid <= 4194304)) || usage
|
||||
wanted+="$((10#$pid)) "
|
||||
shift 2
|
||||
;;
|
||||
--signal)
|
||||
[[ ${2-} == "INT" || ${2-} == "KILL" ]] || usage
|
||||
signal=$2
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
is_recorder() {
|
||||
local exe
|
||||
exe=$(readlink "$proc_root/$1/exe" 2>/dev/null) || return 1
|
||||
# Linux appends this when an upgrade unlinks a still-running executable.
|
||||
exe=${exe% (deleted)}
|
||||
exe=${exe##*/}
|
||||
[[ $exe == "gpu-screen-recorder" || $exe == "wf-recorder" ]]
|
||||
}
|
||||
|
||||
# Linux truncates a process name to 15 characters: gpu-screen-recorder runs as
|
||||
# gpu-screen-reco, which any executable sharing that prefix also matches, and a
|
||||
# process can rename itself, so the executable path confirms each candidate.
|
||||
matched=()
|
||||
while read -r pid; do
|
||||
if [[ $wanted == " " || $wanted == *" $pid "* ]] && is_recorder "$pid"; then
|
||||
matched+=("$pid")
|
||||
fi
|
||||
done < <(pgrep -u "$UID" -x 'gpu-screen-reco(rder)?|wf-recorder')
|
||||
|
||||
if ((${#matched[@]} == 0)); then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n $signal ]]; then
|
||||
delivered=0
|
||||
for pid in "${matched[@]}"; do
|
||||
# The pid can be reused between discovery and kill; re-checking exe here narrows that window.
|
||||
if is_recorder "$pid" && kill "-$signal" "$pid" 2>/dev/null; then
|
||||
delivered=1
|
||||
fi
|
||||
done
|
||||
# A recorder that exits before its signal has stopped anyway; fail only if none was signalled.
|
||||
((delivered)) || exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -2,22 +2,81 @@
|
||||
|
||||
# omarchy:summary=Take a screenshot
|
||||
# omarchy:group=capture
|
||||
# omarchy:args=[smart|region|windows|fullscreen|scroll] [copy|save]
|
||||
# omarchy:args=[smart|region|windows|fullscreen] [slurp|copy|save] [--editor=<name>]
|
||||
# omarchy:examples=omarchy screenshot | omarchy capture screenshot region
|
||||
# omarchy:aliases=omarchy screenshot
|
||||
|
||||
if [[ -n ${OMARCHY_SCREENSHOT_DIR:-} && -z ${OMASNAP_SCREENSHOT_DIR:-} ]]; then
|
||||
export OMASNAP_SCREENSHOT_DIR="$OMARCHY_SCREENSHOT_DIR"
|
||||
[[ -f ~/.config/user-dirs.dirs ]] && source ~/.config/user-dirs.dirs
|
||||
OUTPUT_DIR="${OMARCHY_SCREENSHOT_DIR:-${XDG_PICTURES_DIR:-$HOME/Pictures}}"
|
||||
|
||||
if [[ ! -d $OUTPUT_DIR ]]; then
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
omarchy-notification-send "Created screenshot directory: $OUTPUT_DIR" -t 2000
|
||||
fi
|
||||
|
||||
omasnap_args=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
copy) omasnap_args+=(--copy) ;;
|
||||
save) omasnap_args+=(--save) ;;
|
||||
slurp) ;;
|
||||
*) omasnap_args+=("$arg") ;;
|
||||
esac
|
||||
done
|
||||
pkill slurp && exit 0
|
||||
|
||||
exec omasnap "${omasnap_args[@]}"
|
||||
SCREENSHOT_EDITOR="${OMARCHY_SCREENSHOT_EDITOR:-tensaku-edit}"
|
||||
|
||||
# Parse --editor flag from any position
|
||||
ARGS=()
|
||||
for arg in "$@"; do
|
||||
if [[ $arg == --editor=* ]]; then
|
||||
SCREENSHOT_EDITOR="${arg#--editor=}"
|
||||
else
|
||||
ARGS+=("$arg")
|
||||
fi
|
||||
done
|
||||
set -- "${ARGS[@]}"
|
||||
|
||||
MODE="${1:-smart}"
|
||||
PROCESSING="${2:-slurp}"
|
||||
|
||||
# The picker leaves the screen freeze running (PID on its first output line)
|
||||
# so grim captures the frozen overlay rather than live content shifting
|
||||
# during teardown.
|
||||
#
|
||||
# Software-composited cursors (Hyprland's fallback on GPUs without working
|
||||
# hardware cursors) are baked into the frames grim captures, so force
|
||||
# hardware cursors until after grim runs and restore the setting on exit.
|
||||
NO_HW_CURSORS=$(hyprctl getoption cursor:no_hardware_cursors -j | jq '.int')
|
||||
|
||||
set_no_hw_cursors() {
|
||||
hyprctl eval "hl.config({ cursor = { no_hardware_cursors = $1 } })" &>/dev/null ||
|
||||
hyprctl keyword cursor:no_hardware_cursors "$1" &>/dev/null
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
[[ -n $FREEZE_PID ]] && kill $FREEZE_PID 2>/dev/null
|
||||
set_no_hw_cursors "$NO_HW_CURSORS"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
set_no_hw_cursors 0
|
||||
{ read -r FREEZE_PID; read -r SELECTION; } < <(omarchy-capture-region "$MODE" --keep-freeze)
|
||||
|
||||
[[ -z $SELECTION ]] && exit 0
|
||||
|
||||
FILENAME="screenshot-$(date +'%Y-%m-%d_%H-%M-%S').png"
|
||||
FILEPATH="$OUTPUT_DIR/$FILENAME"
|
||||
|
||||
case "$PROCESSING" in
|
||||
slurp)
|
||||
grim -g "$SELECTION" "$FILEPATH" || exit 1
|
||||
echo "$FILEPATH"
|
||||
wl-copy --type image/png <"$FILEPATH"
|
||||
|
||||
# Best-effort: the screenshot is already saved and on the clipboard, so a
|
||||
# notification outage must not report the capture itself as failed.
|
||||
omarchy-notification-send "Screenshot saved to clipboard and file" "Edit with Super + Alt + , (or click this)" \
|
||||
--image "$FILEPATH" \
|
||||
--exec "$SCREENSHOT_EDITOR" "$FILEPATH" || true
|
||||
;;
|
||||
copy)
|
||||
grim -g "$SELECTION" - | wl-copy --type image/png
|
||||
;;
|
||||
save)
|
||||
grim -g "$SELECTION" "$FILEPATH" || exit 1
|
||||
echo "$FILEPATH"
|
||||
;;
|
||||
esac
|
||||
@@ -8,10 +8,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
readonly MARGIN=40
|
||||
# Has to resolve to whatever omarchy-capture-screenrecording writes, fallback
|
||||
# included, or a recording without a session runtime dir anchors the camera to
|
||||
# the whole monitor instead of the region it recorded.
|
||||
readonly REGION_FILE="${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/omarchy}/omarchy-screenrecord-region"
|
||||
readonly REGION_FILE="${XDG_RUNTIME_DIR:-/tmp}/omarchy-screenrecord-region"
|
||||
|
||||
usage() {
|
||||
echo "Usage: omarchy-capture-webcam-resize <smaller|larger|reset|small|medium|large>" >&2
|
||||
|
||||
+10
-89
@@ -1,25 +1,10 @@
|
||||
#!/bin/bash -p
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Set the Omarchy package channel.
|
||||
# omarchy:args=<stable|rc|edge|dev>
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
if [[ $- != *p* ]]; then
|
||||
echo "Refusing an unsafe Bash startup for channel switching." >&2
|
||||
exit 126
|
||||
fi
|
||||
security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126
|
||||
source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126
|
||||
omarchy_security_require_privileged_bash_startup || exit 126
|
||||
set -euo pipefail
|
||||
omarchy_security_sanitize_bash_environment "$0" "$@"
|
||||
omarchy_security_require_source_root "$0"
|
||||
user_path=$PATH
|
||||
# Traps first, so a signal or failure during the entry revocation still
|
||||
# exits through the cleanup path.
|
||||
omarchy_security_install_sudo_cleanup_traps
|
||||
omarchy_security_revoke_sudo_timestamp || exit 1
|
||||
omarchy_security_enable_no_update_sudo
|
||||
|
||||
usage() { echo "Usage: omarchy-channel-set [stable|rc|edge|dev]"; }
|
||||
fail() { echo "Error: $*" >&2; exit 1; }
|
||||
@@ -48,57 +33,12 @@ validate_dev_checkout() {
|
||||
}
|
||||
|
||||
link_dev_checkout() {
|
||||
local checkout="$1" required
|
||||
[[ -d $checkout/.git ]] || git clone https://github.com/omacom/omarchy.git "$checkout"
|
||||
|
||||
# Check the destination before changing /etc/omarchy.conf or sudo's path.
|
||||
# An existing checkout is not pulled automatically and may predate this policy.
|
||||
for required in bin/omarchy-security-functions bin/omarchy-update bin/omarchy-refresh-pacman default/omarchy/sudo-no-update/sudo; do
|
||||
if [[ ! -f $checkout/$required || ! -r $checkout/$required ||
|
||||
( $required != "bin/omarchy-security-functions" && ! -x $checkout/$required ) ]]; then
|
||||
fail "Update the checkout before switching to dev; missing required update support in $required."
|
||||
fi
|
||||
done
|
||||
|
||||
# The checkout's own channel refresh runs next, from its own templates.
|
||||
if [[ ! -f $checkout/${templates#"$OMARCHY_PATH"/}/pacman-$pacman_channel.conf ||
|
||||
! -f $checkout/${templates#"$OMARCHY_PATH"/}/mirrorlist-$pacman_channel ]]; then
|
||||
fail "Update the checkout before switching to dev; it has no $pacman_channel templates for $platform."
|
||||
fi
|
||||
|
||||
# On aarch64 one from before it had platform templates would replace this
|
||||
# machine's repositories with x86's.
|
||||
if [[ $platform != "x86" ]]; then
|
||||
for required in bin/omarchy-hw-platform install/helpers/pacman.sh; do
|
||||
if [[ ! -f $checkout/$required || ! -r $checkout/$required ||
|
||||
( $required == "bin/omarchy-hw-platform" && ! -x $checkout/$required ) ]]; then
|
||||
fail "Update the checkout before switching to dev; on $platform it needs $required for this machine's repositories."
|
||||
fi
|
||||
done
|
||||
fi
|
||||
local checkout="$1"
|
||||
[[ -d $checkout/.git ]] || git clone https://github.com/basecamp/omarchy.git "$checkout"
|
||||
|
||||
omarchy-dev-link "$checkout" --no-reboot
|
||||
}
|
||||
|
||||
# A packaged destination cannot be inspected before its package is installed,
|
||||
# and a package transaction can replace the running tree with a release that
|
||||
# predates the command-scoped wrapper. After that, a bare sudo would resolve to
|
||||
# /usr/bin/sudo and publish a timestamp, and the destination's own updater
|
||||
# authenticates the same way. Neither may run from a flow that has just run
|
||||
# user hooks: stop at a consistent point and say how to finish from a fresh
|
||||
# session.
|
||||
stop_for_older_destination() {
|
||||
cat >&2 <<EOF
|
||||
The destination predates command-scoped sudo, so this switch stops before its update.
|
||||
Packages are switched. Run 'omarchy update' from a new terminal to finish, then reboot if prompted.
|
||||
EOF
|
||||
exit 3
|
||||
}
|
||||
|
||||
wrapper_present() {
|
||||
[[ -f $OMARCHY_PATH/default/omarchy/sudo-no-update/sudo && -x $OMARCHY_PATH/default/omarchy/sudo-no-update/sudo ]]
|
||||
}
|
||||
|
||||
(( $# > 0 )) || { usage; exit 1; }
|
||||
|
||||
dev_checkout=""
|
||||
@@ -119,6 +59,9 @@ case "$channel" in
|
||||
packages=(omarchy-dev omarchy-settings-dev)
|
||||
;;
|
||||
dev)
|
||||
confirm_dev || { echo "Cancelled."; exit 0; }
|
||||
dev_checkout="$HOME/omarchy"
|
||||
validate_dev_checkout "$dev_checkout"
|
||||
pacman_channel=edge
|
||||
packages=(omarchy-dev omarchy-settings-dev)
|
||||
;;
|
||||
@@ -129,24 +72,6 @@ case "$channel" in
|
||||
;;
|
||||
esac
|
||||
|
||||
# A channel this machine's platform has no templates for is refused before
|
||||
# anything changes (install/helpers/pacman.sh).
|
||||
source "$OMARCHY_PATH/install/helpers/pacman.sh"
|
||||
platform=$(omarchy-hw-platform) || fail "Cannot tell this machine's platform; nothing changed."
|
||||
templates=$(omarchy_pacman_templates "$platform") || exit 1
|
||||
[[ -f $templates/pacman-$pacman_channel.conf && -f $templates/mirrorlist-$pacman_channel ]] ||
|
||||
fail "Omarchy has no $pacman_channel channel for $platform; nothing changed."
|
||||
|
||||
if [[ $channel == "dev" ]]; then
|
||||
confirm_dev || { echo "Cancelled."; exit 0; }
|
||||
dev_checkout="$HOME/omarchy"
|
||||
validate_dev_checkout "$dev_checkout"
|
||||
fi
|
||||
|
||||
# A failure past this point leaves the channel switch half-applied, so say how
|
||||
# to pick it back up rather than dying silently under set -e.
|
||||
trap 'echo -e "\nThe channel switch did not complete. Review the error above, then rerun: omarchy-channel-set '"$channel"'" >&2' ERR
|
||||
|
||||
if [[ -z $dev_checkout && $OMARCHY_PATH != "/usr/share/omarchy" ]]; then
|
||||
leaving_dev=1
|
||||
fi
|
||||
@@ -154,16 +79,13 @@ fi
|
||||
if [[ -n $dev_checkout ]]; then
|
||||
link_dev_checkout "$dev_checkout"
|
||||
export OMARCHY_PATH="$dev_checkout"
|
||||
omarchy_security_enable_no_update_sudo
|
||||
export PATH="$OMARCHY_PATH/bin:$PATH"
|
||||
omarchy-state set reboot-required
|
||||
fi
|
||||
|
||||
OMARCHY_UPDATE_USER_PATH="$user_path" omarchy-refresh-pacman "$pacman_channel"
|
||||
# Each transaction can have replaced this tree; check before the next sudo.
|
||||
wrapper_present || stop_for_older_destination
|
||||
omarchy-refresh-pacman "$pacman_channel"
|
||||
# --ask 4 accepts omarchy <-> omarchy-dev replacement prompts without file overwrites.
|
||||
omarchy-update-pacman -S --needed --noconfirm --ask 4 "${packages[@]}"
|
||||
wrapper_present || stop_for_older_destination
|
||||
sudo env OMARCHY_UPDATE_PACMAN=1 pacman -S --needed --noconfirm --ask 4 "${packages[@]}"
|
||||
|
||||
if [[ -z $dev_checkout ]]; then
|
||||
omarchy-dev-unlink --no-reboot
|
||||
@@ -172,7 +94,6 @@ if [[ -z $dev_checkout ]]; then
|
||||
if (( leaving_dev )); then
|
||||
omarchy-state set reboot-required
|
||||
fi
|
||||
omarchy_security_enable_no_update_sudo 2>/dev/null || stop_for_older_destination
|
||||
fi
|
||||
|
||||
OMARCHY_UPDATE_USER_PATH="$user_path" "$OMARCHY_PATH/bin/omarchy-update" -y
|
||||
omarchy-update -y
|
||||
@@ -30,7 +30,7 @@ open_image() {
|
||||
local path="$1"
|
||||
|
||||
[[ -r $path ]] || exit 1
|
||||
exec omasnap "$path"
|
||||
exec tensaku-edit "$path"
|
||||
}
|
||||
|
||||
open_text() {
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Hand a command line to the running Chromium-based browser
|
||||
# omarchy:args=<browser-command> [browser-args...]
|
||||
# omarchy:hidden=true
|
||||
|
||||
# A second browser process spends ~165ms starting up only to pass its command
|
||||
# line to the running one over the profile's singleton socket, then exits. Send
|
||||
# it there directly, and fail when no browser acknowledges it so the caller can
|
||||
# launch one instead.
|
||||
config="${XDG_CONFIG_HOME:-$HOME/.config}"
|
||||
|
||||
# Only the browsers themselves: a wrapper in their place may pick a profile or
|
||||
# add flags of its own, which only running it can honour.
|
||||
case $(type -P "$1") in
|
||||
/usr/bin/chromium) profile=chromium flags=chromium-flags.conf ;;
|
||||
/usr/bin/google-chrome-stable) profile=google-chrome flags=chrome-flags.conf ;;
|
||||
/usr/bin/brave) profile=BraveSoftware/Brave-Browser flags=brave-flags.conf ;;
|
||||
/usr/bin/brave-origin) profile=BraveSoftware/Brave-Origin flags=brave-origin-flags.conf ;;
|
||||
/usr/bin/microsoft-edge-stable) profile=microsoft-edge flags=microsoft-edge-stable-flags.conf ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
|
||||
# The running browser already has the process flags Omarchy ships in the flags
|
||||
# file. Any other flag there may be meant for each launch, and a data directory
|
||||
# from the environment is another browser, so leave those to the launcher.
|
||||
if [[ -n ${CHROME_USER_DATA_DIR:-}${CHROME_CONFIG_HOME:-} ]] ||
|
||||
cat "/etc/$flags" "$config/$flags" 2>/dev/null | sed 's/#.*//' | tr -s '[:space:]' '\n' |
|
||||
grep -vE '^(--(ozone-platform|ozone-platform-hint|password-store|enable-features|load-extension)=.*)?$' | grep -q .; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for arg in "$@"; do
|
||||
[[ $arg == --user-data-dir* ]] && exit 1
|
||||
done
|
||||
|
||||
# The socket lives in a /tmp dir, which another user could recreate once a
|
||||
# crashed browser's is cleaned away, so only a socket of our own gets the URL.
|
||||
socket="$config/$profile/SingletonSocket"
|
||||
[[ -S $socket && -O $socket ]] || exit 1
|
||||
|
||||
reply=$({
|
||||
printf 'START\0%s\0browser' "$PWD"
|
||||
printf '\0%s' "$@"
|
||||
} | socat -t2 - "UNIX-CONNECT:$socket" 2>/dev/null)
|
||||
|
||||
[[ $reply == "ACK" ]]
|
||||
@@ -1,58 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Print the desktop entry ID of the default web browser
|
||||
# omarchy:hidden=true
|
||||
|
||||
# Read the http handler from the config-dir mimeapps.list files the way xdg-mime
|
||||
# does, desktop-specific list first, taking the first listed entry that is
|
||||
# installed. xdg-settings reaches the same answer through several shell scripts
|
||||
# in ~70ms, which every browser and web app launch would otherwise pay.
|
||||
IFS=: read -ra data_dirs <<<"${XDG_DATA_HOME:-$HOME/.local/share}:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"
|
||||
IFS=: read -ra config_dirs <<<"${XDG_CONFIG_HOME:-$HOME/.config}:${XDG_CONFIG_DIRS:-/etc/xdg}"
|
||||
IFS=: read -ra desktops <<<"${XDG_CURRENT_DESKTOP:-}"
|
||||
|
||||
# Installed means the command of the entry the launchers will read, the first
|
||||
# one found, still resolves: an entry of Omarchy's own can outlive the browser
|
||||
# it launched.
|
||||
installed() {
|
||||
local data_dir command
|
||||
|
||||
for data_dir in "${data_dirs[@]}"; do
|
||||
[[ -r $data_dir/applications/$1 ]] || continue
|
||||
command=$(sed -n 's/^Exec=\([^ ]*\).*/\1/p' "$data_dir/applications/$1" | head -1)
|
||||
[[ -n $command ]] && command -v "$command" >/dev/null
|
||||
return
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
for config_dir in "${config_dirs[@]}"; do
|
||||
for desktop in "${desktops[@]}" ""; do
|
||||
list="$config_dir/${desktop:+${desktop,,}-}mimeapps.list"
|
||||
[[ -f $list ]] || continue
|
||||
|
||||
entries=$(awk '
|
||||
/^\[Default Applications\]/ { section = 1; next }
|
||||
/^\[/ { section = 0 }
|
||||
section && index($0, "x-scheme-handler/http=") == 1 { print substr($0, 23); exit }
|
||||
' "$list")
|
||||
|
||||
IFS=';' read -ra entries <<<"$entries"
|
||||
for entry in "${entries[@]}"; do
|
||||
if [[ -n $entry ]] && installed "$entry"; then
|
||||
echo "$entry"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
done
|
||||
done
|
||||
|
||||
# Nothing set in a config dir: let xdg-settings search the data dirs and caches.
|
||||
# It consults $BROWSER too, which the session points back at omarchy-launch-browser.
|
||||
browser=$(env -u BROWSER xdg-settings get default-web-browser 2>/dev/null)
|
||||
if [[ -z $browser ]]; then
|
||||
browser=$(xdg-mime query default x-scheme-handler/https 2>/dev/null)
|
||||
fi
|
||||
|
||||
[[ -n $browser ]] && echo "$browser"
|
||||
@@ -3,7 +3,7 @@
|
||||
# omarchy:summary=Check whether any required commands are missing
|
||||
|
||||
for cmd in "$@"; do
|
||||
if ! command -v -- "$cmd" &>/dev/null; then
|
||||
if ! command -v "$cmd" &>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# omarchy:summary=Check whether all required commands are available
|
||||
|
||||
for cmd in "$@"; do
|
||||
command -v -- "$cmd" &>/dev/null || exit 1
|
||||
command -v "$cmd" &>/dev/null || exit 1
|
||||
done
|
||||
|
||||
exit 0
|
||||
@@ -1,10 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Print the current working directory of the active terminal window
|
||||
# omarchy:args=[terminal-pid]
|
||||
# omarchy:hidden=true
|
||||
|
||||
terminal_pid=${1:-$(hyprctl activewindow | awk '/pid:/ {print $2}')}
|
||||
terminal_pid=$(hyprctl activewindow | awk '/pid:/ {print $2}')
|
||||
kitty_socket="$XDG_RUNTIME_DIR/omarchy-kitty-$terminal_pid"
|
||||
cwd=""
|
||||
|
||||
@@ -12,16 +11,7 @@ if [[ -S $kitty_socket ]]; then
|
||||
cwd=$(kitten @ --to "unix:$kitty_socket" ls --match "state:focused" 2>/dev/null |
|
||||
jq -r '.[].tabs[].windows[].cwd // empty')
|
||||
else
|
||||
# The newest child, as pgrep -P | tail -n1 found it, read from the kernel's
|
||||
# child lists: pgrep scans all of /proc on every new terminal.
|
||||
shell_pid=""
|
||||
for children_file in /proc/"$terminal_pid"/task/*/children; do
|
||||
[[ -r $children_file ]] || continue
|
||||
read -ra children <"$children_file" || true
|
||||
for child in "${children[@]}"; do
|
||||
(( child > ${shell_pid:-0} )) && shell_pid=$child
|
||||
done
|
||||
done
|
||||
shell_pid=$(pgrep -P "$terminal_pid" | tail -n1)
|
||||
|
||||
if [[ -n $shell_pid ]]; then
|
||||
cwd=$(readlink -f "/proc/$shell_pid/cwd" 2>/dev/null)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Set and launch the default coding agent
|
||||
# omarchy:args=[pi|omp|opencode|ori|claude|codex|grok|openclaw|agy|hermes|copilot|crush|cursor-agent|muse]
|
||||
# omarchy:args=[pi|omp|opencode|ori|claude|codex|grok|agy|copilot|crush]
|
||||
# omarchy:examples=omarchy default agent | omarchy default agent codex | omarchy default agent claude
|
||||
|
||||
installing=false
|
||||
@@ -31,57 +31,23 @@ ori | openrouter) agent="ori"; name="Ori"; agent_package="github:OpenRouterLabs/
|
||||
claude | claude-code) agent="claude"; name="Claude Code" ;;
|
||||
codex) agent="codex"; name="Codex" ;;
|
||||
crush) agent="crush"; name="Crush" ;;
|
||||
grok) agent="grok"; name="Grok" ;;
|
||||
openclaw) agent="openclaw"; name="OpenClaw"; agent_installer="omarchy-install-openclaw-cli" ;;
|
||||
grok) agent="grok"; name="Grok"; agent_package="npm:@xai-official/grok" ;;
|
||||
agy | antigravity | antigravity-cli | gemini | gemini-cli) agent="agy"; name="Antigravity"; agent_package="antigravity-cli" ;;
|
||||
hermes) agent="hermes"; name="Hermes"; agent_installer="omarchy-install-hermes-cli" ;;
|
||||
copilot | github-copilot) agent="copilot"; name="GitHub Copilot" ;;
|
||||
muse | muse-code | musecode)
|
||||
agent="muse"; name="Muse Code"
|
||||
# Meta's launcher verifies and updates the native binary for this platform.
|
||||
agent_package="http:muse[url=https://api.meta.ai/muse-launcher.sh,bin=muse,version_list_url=https://api.meta.ai/muse-code/channels/muse-stable,version_json_path=.version]"
|
||||
;;
|
||||
cursor | cursor-agent) agent="cursor-agent"; name="Cursor CLI" ;;
|
||||
*)
|
||||
echo "Usage: omarchy-default-agent <pi|omp|opencode|ori|claude|codex|grok|openclaw|agy|hermes|copilot|crush|cursor-agent|muse>"
|
||||
echo "Usage: omarchy-default-agent <pi|omp|opencode|ori|claude|codex|grok|agy|copilot|crush>"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
agent_package=${agent_package:-$agent}
|
||||
|
||||
# Hermes and OpenClaw are not mise tools: each is a self-updating runtime in
|
||||
# the user's home, Hermes the desktop app's and OpenClaw the one its Control
|
||||
# UI updates. Each has its own installer with the same --check/--now contract
|
||||
# mise-backed agents get from mise; see omarchy-install-hermes-cli and
|
||||
# omarchy-install-openclaw-cli.
|
||||
if [[ -n ${agent_installer:-} ]]; then
|
||||
# Not omarchy-cmd-present: a command on PATH says nothing about whether the
|
||||
# install behind it finished or runs the sessions omarchy-agent starts.
|
||||
# Treating it as installed would skip the floating terminal and run a
|
||||
# minutes-long install inside the menu action instead.
|
||||
agent_present() { "$agent_installer" --check; }
|
||||
agent_install() { "$agent_installer" --now; }
|
||||
else
|
||||
# Anything at the wrapper's path other than the wrapper is the user's own
|
||||
# install, such as the symlink Cursor's installer leaves. A mise copy would
|
||||
# only shadow it, since the mise shims precede ~/.local/bin on PATH.
|
||||
user_install() {
|
||||
[[ -x $HOME/.local/bin/$agent ]] &&
|
||||
{ [[ -L $HOME/.local/bin/$agent ]] || ! grep -q '^mise use -g' "$HOME/.local/bin/$agent"; }
|
||||
}
|
||||
agent_present() { user_install || mise where "$agent_package" &>/dev/null; }
|
||||
agent_install() { user_install || mise use -g "$agent_package"; }
|
||||
fi
|
||||
|
||||
if [[ $installing == "false" ]] && ! agent_present; then
|
||||
if [[ $installing == "false" ]] && ! mise where "$agent_package" &>/dev/null; then
|
||||
exec omarchy-launch-floating-terminal-with-presentation omarchy-default-agent --install "$agent"
|
||||
fi
|
||||
|
||||
if ! agent_install; then
|
||||
if [[ $installing == "true" && -n ${agent_installer:-} ]]; then
|
||||
echo "Could not install $name" >&2
|
||||
elif [[ $installing == "true" ]]; then
|
||||
if ! mise use -g "$agent_package"; then
|
||||
if [[ $installing == "true" ]]; then
|
||||
echo "Could not install $name with mise" >&2
|
||||
else
|
||||
echo "Could not set $name as the default coding agent" >&2
|
||||
@@ -89,10 +55,6 @@ if ! agent_install; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ $agent == "claude" ]]; then
|
||||
omarchy-install-chromium-claude 2>/dev/null || true
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$agent_file")"
|
||||
printf '%s\n' "$agent" >"$agent_file"
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Get or set the default dictation backend
|
||||
# omarchy:args=[backend]
|
||||
# omarchy:examples=omarchy default dictation | omarchy default dictation superwhisper
|
||||
|
||||
if (($# > 1)); then
|
||||
echo "Usage: omarchy default dictation [backend]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if (($# == 0)); then
|
||||
config="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/defaults/dictation"
|
||||
if [[ ! -s $config ]]; then
|
||||
echo "No dictation backend configured. Select one in Setup > Defaults > Dictation." >&2
|
||||
exit 1
|
||||
fi
|
||||
read -r backend < "$config"
|
||||
else
|
||||
backend=$1
|
||||
fi
|
||||
|
||||
if [[ ! $backend =~ ^[a-z0-9][a-z0-9-]*$ ]]; then
|
||||
echo "Invalid dictation backend: $backend" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if (($# == 0)); then
|
||||
echo "$backend"
|
||||
else
|
||||
installer="omarchy-install-dictation-$backend"
|
||||
if omarchy-cmd-missing "$installer"; then
|
||||
echo "No installer available for dictation backend: $backend" >&2
|
||||
exit 1
|
||||
fi
|
||||
omarchy-launch-floating-terminal-with-presentation "$installer"
|
||||
fi
|
||||
@@ -17,9 +17,6 @@ if (($# == 0)); then
|
||||
read -r editor <"$editor_file"
|
||||
fi
|
||||
|
||||
# sublime-text-4 ships `subl`; older defaults stored `sublime_text`.
|
||||
[[ $editor == sublime_text ]] && editor=subl
|
||||
|
||||
[[ -n $editor ]] && echo "$editor" || echo "nvim"
|
||||
exit 0
|
||||
fi
|
||||
@@ -28,10 +25,10 @@ case "$1" in
|
||||
code) selection="code"; editor="code"; name="VSCode"; glyph= ;;
|
||||
cursor) selection="cursor"; editor="cursor"; name="Cursor"; glyph= ;;
|
||||
zed | zeditor) selection="zed"; editor="zeditor"; name="Zed"; glyph= ;;
|
||||
sublime_text | subl) selection="sublime_text"; editor="subl"; name="Sublime Text"; glyph= ;;
|
||||
sublime_text) selection="sublime_text"; editor="sublime_text"; name="Sublime Text"; glyph= ;;
|
||||
helix) selection="helix"; editor="helix"; name="Helix"; glyph= ;;
|
||||
vim) selection="vim"; editor="vim"; name="Vim"; glyph= ;;
|
||||
emacs) selection="emacs"; editor="emacs"; name="Emacs"; glyph= ;;
|
||||
emacs) selection="emacs"; editor="emacs"; name="Emacs"; glyph= ;;
|
||||
nvim) selection="nvim"; editor="nvim"; name="Neovim"; glyph= ;;
|
||||
*)
|
||||
echo "Usage: omarchy-default-editor <code|cursor|zed|sublime_text|helix|vim|emacs|nvim>"
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Record dictation with the configured backend
|
||||
# omarchy:args=<start|stop|toggle>
|
||||
# omarchy:examples=omarchy dictation start | omarchy dictation toggle
|
||||
|
||||
if (($# != 1)) || [[ $1 != "start" && $1 != "stop" && $1 != "toggle" ]]; then
|
||||
echo "Usage: omarchy dictation <start|stop|toggle>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
config="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/defaults/dictation"
|
||||
mkdir -p "${config%/*}"
|
||||
exec 9> "$config.lock"
|
||||
if ! flock -w 5 9; then
|
||||
echo "Dictation is busy; try again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
backend=$(omarchy-default-dictation) || exit 1
|
||||
adapter="omarchy-dictation-$backend"
|
||||
if omarchy-cmd-missing "$adapter"; then
|
||||
echo "The selected dictation backend ($backend) is not installed." >&2
|
||||
exit 1
|
||||
fi
|
||||
"$adapter" "$1" 9>&-
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Record dictation using Superwhisper
|
||||
# omarchy:args=<start|stop|toggle>
|
||||
|
||||
case "${1:-}" in
|
||||
start) superwhisper start ;;
|
||||
stop)
|
||||
if omarchy-cmd-missing superwhisper || ! systemctl --user is-active --quiet superwhisper.service; then
|
||||
exit 0
|
||||
else
|
||||
superwhisper stop
|
||||
fi
|
||||
;;
|
||||
toggle) superwhisper record ;;
|
||||
*) echo "Usage: omarchy dictation superwhisper <start|stop|toggle>" >&2; exit 1 ;;
|
||||
esac
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:hidden=true
|
||||
# omarchy:summary=Save a configured dictation backend after stopping the previous backend
|
||||
# omarchy:args=<backend>
|
||||
|
||||
set -e
|
||||
|
||||
if (($# != 1)) || [[ ! $1 =~ ^[a-z0-9][a-z0-9-]*$ ]]; then
|
||||
echo "Usage: omarchy-dictation-use <backend>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
config="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/defaults/dictation"
|
||||
mkdir -p "${config%/*}"
|
||||
exec 9> "$config.lock"
|
||||
if ! flock -w 5 9; then
|
||||
echo "Dictation is busy; try again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
previous=$(omarchy-default-dictation 2>/dev/null) || previous=""
|
||||
if [[ -n $previous && $previous != "$1" ]] && omarchy-cmd-present "omarchy-dictation-$previous"; then
|
||||
if ! "omarchy-dictation-$previous" stop 9>&-; then
|
||||
echo "Could not stop $previous; keeping the existing dictation backend." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
temporary=$(mktemp "$config.XXXXXX")
|
||||
trap 'rm -f "$temporary"' EXIT
|
||||
printf '%s\n' "$1" > "$temporary"
|
||||
mv -f "$temporary" "$config"
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Record dictation using Voxtype
|
||||
# omarchy:args=<start|stop|toggle>
|
||||
|
||||
case "${1:-}" in
|
||||
start|toggle) voxtype record "$1" ;;
|
||||
stop)
|
||||
if omarchy-cmd-missing voxtype || [[ $(voxtype status) == "stopped" ]]; then
|
||||
exit 0
|
||||
else
|
||||
voxtype record stop
|
||||
fi
|
||||
;;
|
||||
*) echo "Usage: omarchy dictation voxtype <start|stop|toggle>" >&2; exit 1 ;;
|
||||
esac
|
||||
@@ -129,8 +129,7 @@ term_pt_for() {
|
||||
'BEGIN { printf "%d", int(s * p / b + 0.5) }'
|
||||
}
|
||||
|
||||
# Set the font point size in terminal configs, creating Kitty overrides when
|
||||
# it inherits its size from the system config. Family is left
|
||||
# Set the font point size in every terminal config that exists. Family is left
|
||||
# untouched — that is omarchy-font-set's job. Live-reload signals mirror
|
||||
# omarchy-font-set; foot has no reload signal, so running instances are nudged.
|
||||
set_terminal_size() {
|
||||
@@ -140,13 +139,8 @@ set_terminal_size() {
|
||||
sed -i -E "s/^size[[:space:]]*=.*/size = $pt/" ~/.config/alacritty/alacritty.toml
|
||||
fi
|
||||
|
||||
if [[ -f ~/.config/kitty/kitty.conf ]] || omarchy-cmd-present kitty; then
|
||||
mkdir -p ~/.config/kitty
|
||||
if grep -qE '^[[:space:]]*font_size[[:space:]]+' ~/.config/kitty/kitty.conf 2>/dev/null; then
|
||||
sed --follow-symlinks -i -E "s/^[[:space:]]*font_size[[:space:]]+.*/font_size $pt.0/" ~/.config/kitty/kitty.conf
|
||||
else
|
||||
printf '\nfont_size %s.0\n' "$pt" >>~/.config/kitty/kitty.conf
|
||||
fi
|
||||
if [[ -f ~/.config/kitty/kitty.conf ]]; then
|
||||
sed -i -E "s/^font_size[[:space:]]+.*/font_size $pt.0/" ~/.config/kitty/kitty.conf
|
||||
pkill -USR1 kitty 2>/dev/null || true
|
||||
fi
|
||||
|
||||
@@ -183,13 +177,9 @@ term_current_pt() {
|
||||
elif [[ -f ~/.config/alacritty/alacritty.toml ]]; then
|
||||
grep -oP '^size[[:space:]]*=[[:space:]]*\K[0-9.]+' ~/.config/alacritty/alacritty.toml | head -1
|
||||
elif [[ -f ~/.config/kitty/kitty.conf ]]; then
|
||||
local pt
|
||||
pt=$(grep -oP '^[[:space:]]*font_size[[:space:]]+\K[0-9.]+' ~/.config/kitty/kitty.conf | tail -1)
|
||||
echo "${pt:-$TERM_DEFAULT_PT}"
|
||||
grep -oP '^font_size[[:space:]]+\K[0-9.]+' ~/.config/kitty/kitty.conf | head -1
|
||||
elif [[ -f ~/.config/foot/foot.ini ]]; then
|
||||
grep -oP ':size=\K[0-9.]+' ~/.config/foot/foot.ini | head -1
|
||||
elif omarchy-cmd-present kitty; then
|
||||
echo "$TERM_DEFAULT_PT"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
+19
-419
@@ -1,432 +1,32 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Set a new password for an encrypted drive
|
||||
# omarchy:summary=Set a new encryption password for a drive selected.
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
# Changes a drive's LUKS key and nothing else. For the disk holding /, the new
|
||||
# key must open the disk and the old one must not before the change counts. The
|
||||
# journal records only the disk's UUID, key slots and phase, so a change that
|
||||
# stops part way is finished by the next run. A platform whose boot checks prove
|
||||
# the disk's key slots records the owner's new one before the run finishes.
|
||||
|
||||
# Passwords live in variables; never trace them.
|
||||
set +x
|
||||
|
||||
REKEY_STATE=${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/drive-password.state
|
||||
source "$OMARCHY_PATH/install/provisioning/luks-rekey.sh"
|
||||
|
||||
# The LUKS partition beneath /, or nothing when / is not encrypted. Fails when
|
||||
# it cannot tell, including a crypt layer whose LUKS partition lsblk cannot name.
|
||||
root_luks_device() {
|
||||
local root_source ancestry device
|
||||
root_source=$(findmnt -no SOURCE /) && [[ -n $root_source ]] || return 1
|
||||
ancestry=$(lsblk -nsrpo NAME,TYPE,FSTYPE "${root_source%%[*}") || return 1
|
||||
device=$(awk '$3 == "crypto_LUKS" { print $1; exit }' <<<"$ancestry")
|
||||
if [[ -n $device ]]; then
|
||||
readlink -f "$device"
|
||||
elif awk '$2 == "crypt" { found = 1 } END { exit !found }' <<<"$ancestry"; then
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Every LUKS device, from udev's probe as root_luks_device reads it. An
|
||||
# unprivileged blkid only reads /run/blkid, empty until root runs blkid. lsblk
|
||||
# repeats a device under each parent (RAID members), so each is listed once.
|
||||
luks_drives() {
|
||||
lsblk -nrpo NAME,FSTYPE | awk '$2 == "crypto_LUKS" && !seen[$1]++ { print $1 }'
|
||||
}
|
||||
|
||||
# Every cryptsetup call holds the disk lock, so one left running by a run that
|
||||
# was killed finishes before the next run looks at the drive.
|
||||
|
||||
# Print the key slot the password opens. Returns 2 when it opens none, and
|
||||
# another failure when the drive cannot be checked. cryptsetup open tries
|
||||
# enrolled tokens (TPM2, FIDO2, keyring) before the key and reports a token's
|
||||
# slot whatever key it was given, so allow only a token type no token has.
|
||||
drive_key_slot() {
|
||||
local drive=$1 password=$2 out status=0
|
||||
out=$(printf '%s' "$password" | sudo flock "$disk_lock" env LC_ALL=C cryptsetup open --test-passphrase --verbose --token-type passphrase-only --key-file - "$drive" 2>&1) || status=$?
|
||||
(( status == 0 )) || return "$status"
|
||||
grep -o 'Key slot [0-9]* unlocked' <<<"$out" | grep -o '[0-9]*' | head -1
|
||||
}
|
||||
|
||||
# The enabled key slots, comma-separated. A luks2-keyring token under "Tokens:"
|
||||
# looks like a keyslot, so read only the keyslot section.
|
||||
drive_slots() {
|
||||
local dump
|
||||
dump=$(sudo flock "$disk_lock" cryptsetup luksDump "$1") || return 1
|
||||
awk '
|
||||
/^[^ \t]/ { keyslots = ($0 == "Keyslots:") }
|
||||
keyslots && /^ +[0-9]+: luks2/ { sub(":", "", $1); print $1 }
|
||||
/^Key Slot [0-9]+: ENABLED/ { sub(":", "", $3); print $3 }' <<<"$dump" | paste -sd, -
|
||||
}
|
||||
|
||||
# The key slots other than FOUND that the password also opens, comma-separated,
|
||||
# or nothing. Fails when a slot cannot be checked.
|
||||
other_key_slots() {
|
||||
local drive=$1 password=$2 found=$3 slots slot status
|
||||
local -a others=()
|
||||
slots=$(drive_slots "$drive") || return 1
|
||||
for slot in ${slots//,/ }; do
|
||||
[[ $slot != "$found" ]] || continue
|
||||
status=0
|
||||
printf '%s' "$password" | sudo flock "$disk_lock" cryptsetup open --test-passphrase --token-type passphrase-only --key-slot "$slot" --key-file - "$drive" >/dev/null 2>&1 || status=$?
|
||||
if (( status == 0 )); then
|
||||
others+=("$slot")
|
||||
elif (( status != 2 )); then
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
local IFS=,
|
||||
printf '%s' "${others[*]}"
|
||||
}
|
||||
|
||||
kill_slot() {
|
||||
local drive=$1 password=$2 slot=$3
|
||||
printf '%s' "$password" | sudo flock "$disk_lock" cryptsetup luksKillSlot -q --key-file - "$drive" "$slot"
|
||||
}
|
||||
|
||||
# luksChangeKey writes the new key before it retires the old one (LUKS2 swaps
|
||||
# them in one header update), so a failure that leaves the new key unable to
|
||||
# open the drive changed nothing: that returns 2. One that stops in between
|
||||
# leaves both keys, so the old key is retired here until it opens nothing.
|
||||
# Sets new_slot.
|
||||
change_luks_key() {
|
||||
local drive=$1 current=$2 new=$3 old_slot=$4 remains attempt status
|
||||
local -a pbkdf=(--iter-time 2000)
|
||||
|
||||
echo "Changing full-disk encryption password for $drive"
|
||||
if sudo flock "$disk_lock" cryptsetup isLuks --type luks2 "$drive"; then
|
||||
pbkdf+=(--pbkdf argon2id)
|
||||
fi
|
||||
# Both keys travel over stdin, the current one on the first line.
|
||||
printf '%s\n%s' "$current" "$new" |
|
||||
sudo flock "$disk_lock" bash -c 'IFS= read -r current; drive=$1; shift; exec cryptsetup luksChangeKey "$@" --key-file <(printf "%s" "$current") "$drive" <(cat)' bash "$drive" "${pbkdf[@]}"
|
||||
|
||||
new_slot=$(drive_key_slot "$drive" "$new") || return
|
||||
for (( attempt = 0; attempt < 8; attempt++ )); do
|
||||
status=0
|
||||
remains=$(drive_key_slot "$drive" "$current") || status=$?
|
||||
(( status == 2 )) && return 0
|
||||
(( status == 0 )) && [[ $remains != "$new_slot" ]] || return 1
|
||||
kill_slot "$drive" "$new" "$remains" || return 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# The entrypoint that records the system disk's key slots for the platform's
|
||||
# boot checks, or nothing where none does. A machine without its platform's
|
||||
# boot package at all (dispatch exit 3) predates it, and nothing checks its
|
||||
# slots. Fails with the dispatcher's message otherwise, such as a boot package
|
||||
# too old to ship luks-slots.
|
||||
slot_recorder() {
|
||||
local out status=0
|
||||
out=$("$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" --resolve luks-slots 2>&1) || status=$?
|
||||
if (( status == 0 )); then
|
||||
printf '%s' "$out"
|
||||
elif (( status != 3 )); then
|
||||
printf '%s\n' "$out" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Refuse a current password that opens any slot but the owner's where the
|
||||
# platform records the slots: on a disk an earlier setup gave a second key (a
|
||||
# recovery key), changing that one would leave the owner's password as it was.
|
||||
# luks-slots --owner prints the recorded slot (0), or exits 4 when no owner slot
|
||||
# is recorded, as on a Mac set up before its boot package recorded slots; any
|
||||
# other status is a failure the entrypoint explains, and stops the change.
|
||||
# Where nothing is recorded, a password that opens the disk's only key slot is
|
||||
# the owner's, and the change records its new slot (unrecorded_owner).
|
||||
owner_slot_matches() {
|
||||
local slot=$1 recorder owner errors reason status=0
|
||||
recorder=$(slot_recorder) || return 1
|
||||
[[ -n $recorder ]] || return 0
|
||||
errors=$(mktemp) || return 1
|
||||
owner=$(sudo "$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" luks-slots --owner 2>"$errors") || status=$?
|
||||
reason=$(<"$errors")
|
||||
rm -f "$errors"
|
||||
if (( status == 4 )); then
|
||||
only_key_slot "$slot" || return 1
|
||||
unrecorded_owner=1
|
||||
return 0
|
||||
elif (( status != 0 )); then
|
||||
echo "Could not read which key slot holds the system disk password: ${reason:-luks-slots --owner exited $status}"
|
||||
return 1
|
||||
elif [[ ! $owner =~ ^[0-9]+$ ]]; then
|
||||
echo "Could not read which key slot holds the system disk password: the platform's boot package named '$owner', which is not a key slot."
|
||||
return 1
|
||||
fi
|
||||
if [[ $owner != "$slot" ]]; then
|
||||
echo "That password opens another key slot on the system disk, such as a recovery key from an earlier setup. Enter the system disk password."
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# With no owner slot recorded, the password must open the system disk's one key
|
||||
# slot: with more keys there is no telling which is the owner's, and recording
|
||||
# only the owner's would fail the platform's boot check, which wants every kept
|
||||
# slot recorded. Says how to settle it.
|
||||
only_key_slot() {
|
||||
local slot=$1 slots others
|
||||
slots=$(drive_slots "$root_drive") || { echo "Could not read the key slots of $root_drive."; return 1; }
|
||||
[[ $slots != "$slot" ]] || return 0
|
||||
others=$(tr ',' '\n' <<<"$slots" | grep -vx -- "$slot" | paste -sd, -)
|
||||
echo "No key slot is recorded as the system disk password's, and $root_drive has keys in slots $slots, so which one is yours can't be told. The password you entered opens slot $slot."
|
||||
if [[ $others =~ ^[0-9]+$ ]]; then
|
||||
echo "If that password is yours and slot $others holds a recovery key you keep, record both: sudo omarchy-lifecycle-dispatch luks-slots owner=$slot recovery=$others"
|
||||
echo "Otherwise remove the key you don't use: sudo cryptsetup luksKillSlot $root_drive <slot>"
|
||||
else
|
||||
echo "Remove the keys you don't use: sudo cryptsetup luksKillSlot $root_drive <slot>"
|
||||
fi
|
||||
echo "Then run omarchy-drive-password again."
|
||||
return 1
|
||||
}
|
||||
|
||||
# Record the slot the owner's password opens now; a no-op where nothing does.
|
||||
record_owner_slot() {
|
||||
local recorder
|
||||
recorder=$(slot_recorder) || return 1
|
||||
[[ -z $recorder ]] || sudo "$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" luks-slots "owner=$1"
|
||||
}
|
||||
|
||||
# Phase luks: the key change may or may not have landed. Phase record: the new
|
||||
# key alone opens the recorded slot, which the boot checks still need.
|
||||
change_system_password() {
|
||||
local current=$1 new=$2 old_slot=$3 slots status=0
|
||||
|
||||
slots=$(drive_slots "$root_drive")
|
||||
if [[ ,$slots, != *",$old_slot,"* ]]; then
|
||||
echo "Could not read the key slots of $root_drive."
|
||||
return 1
|
||||
fi
|
||||
# A disk taken as the owner's for holding one key must still hold only it.
|
||||
if (( unrecorded_owner )) && [[ $slots != "$old_slot" ]]; then
|
||||
echo "The key slots of $root_drive changed while you entered the new password. Run omarchy-drive-password again."
|
||||
return 1
|
||||
fi
|
||||
mkdir -p "${REKEY_STATE%/*}" &&
|
||||
rekey_state_put uuid "$root_uuid" old_slot "$old_slot" slots "$slots" checked 1 phase luks || return 1
|
||||
|
||||
change_luks_key "$root_drive" "$current" "$new" "$old_slot" || status=$?
|
||||
if (( status == 2 )); then
|
||||
rm -f "$REKEY_STATE"
|
||||
echo "The disk password did not change."
|
||||
return 1
|
||||
elif (( status != 0 )); then
|
||||
echo "Could not confirm the disk password change. Run omarchy-drive-password again to finish."
|
||||
return 1
|
||||
fi
|
||||
rekey_state_put slot "$new_slot" phase record || return 1
|
||||
|
||||
if ! record_owner_slot "$new_slot"; then
|
||||
echo "Could not record the new key slot for the boot checks. Run omarchy-drive-password again to finish."
|
||||
return 1
|
||||
fi
|
||||
rm -f "$REKEY_STATE"
|
||||
}
|
||||
|
||||
# A journal left on this system's disk by a change that stopped part way.
|
||||
change_pending() {
|
||||
local phase uuid
|
||||
phase=$(rekey_state_get phase) || return 1
|
||||
uuid=$(rekey_state_get uuid)
|
||||
if [[ $phase == "luks" || $phase == "record" || $phase == "accounts" ]] && [[ -n $uuid && $uuid == "$root_uuid" ]]; then
|
||||
return 0
|
||||
fi
|
||||
rm -f "$REKEY_STATE"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Settle the disk on the password it opens with now, then record its slot.
|
||||
# Before the change was confirmed that is the old key (removing the slot the
|
||||
# change added, if any) or the new key in the one slot the change added
|
||||
# (retiring the old slot); after, only the confirmed slot. Any other key on the
|
||||
# disk, such as a recovery passphrase, is refused, and so is a disk that gained
|
||||
# more than one slot since.
|
||||
finish_interrupted_change() {
|
||||
local phase old_slot slots slot header password opened now added extra count=0 status=0
|
||||
|
||||
phase=$(rekey_state_get phase)
|
||||
# Earlier versions named the confirmed phase "accounts".
|
||||
[[ $phase != "accounts" ]] || phase=record
|
||||
old_slot=$(rekey_state_get old_slot)
|
||||
slots=$(rekey_state_get slots)
|
||||
slot=$(rekey_state_get slot)
|
||||
|
||||
if [[ $phase == "luks" && ! ( $old_slot =~ ^[0-9]+$ && ,$slots, == *",$old_slot,"* ) ]] ||
|
||||
[[ $phase == "record" && ! $slot =~ ^[0-9]+$ ]]; then
|
||||
echo "$REKEY_STATE is unreadable. Remove it once the disk opens with only the password you want."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Earlier versions journaled the change without checking that the old
|
||||
# password opened one slot alone (checked=1), so with other slots on the disk
|
||||
# a copy of it may have outlived the change. No password entered now can show
|
||||
# which slot was whose, so the owner settles that by hand.
|
||||
if [[ -z $(rekey_state_get checked) ]]; then
|
||||
now=$(drive_slots "$root_drive") || { echo "Could not read the key slots of $root_drive."; return 1; }
|
||||
if [[ $now == *,* ]]; then
|
||||
echo "The last password change for $root_drive was started by an earlier version of omarchy-drive-password, and the disk has key slots $now, so it may still hold a copy of the old password."
|
||||
echo "Settle it by hand, in this order:"
|
||||
echo "1. Find the slot the password you want to keep opens, and keep it: sudo cryptsetup open --test-passphrase --token-type passphrase-only --verbose $root_drive names it. If only the old password opens the disk, that is the one you keep."
|
||||
echo "2. If the password you keep is not the old one, check each other slot with the old password (sudo cryptsetup open --test-passphrase --token-type passphrase-only --key-slot <slot> $root_drive) and remove each one it opens (sudo cryptsetup luksKillSlot $root_drive <slot>). Never remove the slot from step 1."
|
||||
if [[ -n $(slot_recorder 2>/dev/null) ]]; then
|
||||
echo "3. Record the slots you kept for the boot checks: sudo omarchy-lifecycle-dispatch luks-slots owner=<slot> recovery=<slot>, with an empty recovery= when no recovery key is left."
|
||||
fi
|
||||
echo "Then remove $REKEY_STATE, and run omarchy-drive-password again if the disk should still take a new password."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "The last password change for $root_drive stopped part way."
|
||||
if [[ $phase == "record" ]]; then
|
||||
header="New encryption password"
|
||||
else
|
||||
header="Encryption password that opens $root_drive now (the new one if the change went through)"
|
||||
fi
|
||||
password=$(gum input --password --header "$header") || return 1
|
||||
|
||||
opened=$(drive_key_slot "$root_drive" "$password") || status=$?
|
||||
if (( status != 0 && status != 2 )); then
|
||||
echo "Could not check the password against $root_drive."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ $phase == "luks" && -n $opened ]]; then
|
||||
now=$(drive_slots "$root_drive") || { echo "Could not read the key slots of $root_drive."; return 1; }
|
||||
for extra in ${now//,/ }; do
|
||||
if [[ ,$slots, != *",$extra,"* ]]; then
|
||||
added=$extra
|
||||
count=$(( count + 1 ))
|
||||
fi
|
||||
done
|
||||
if (( count > 1 )); then
|
||||
echo "The key slots of $root_drive changed since the interrupted change. Finish it by hand."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ $opened == "$old_slot" ]]; then
|
||||
[[ -z $added ]] || kill_slot "$root_drive" "$password" "$added" || return 1
|
||||
elif [[ -n $added && $opened == "$added" ]]; then
|
||||
if [[ ,$now, == *",$old_slot,"* ]]; then
|
||||
kill_slot "$root_drive" "$password" "$old_slot" || return 1
|
||||
fi
|
||||
else
|
||||
opened=""
|
||||
fi
|
||||
[[ -z $opened ]] || rekey_state_put slot "$opened" phase record || return 1
|
||||
elif [[ $phase == "record" && $opened != "$slot" ]]; then
|
||||
opened=""
|
||||
fi
|
||||
|
||||
if [[ -z $opened ]]; then
|
||||
echo "That password does not finish the change on $root_drive. Run omarchy-drive-password again to finish."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! record_owner_slot "$opened"; then
|
||||
echo "Could not record the key slot for the boot checks. Run omarchy-drive-password again to finish."
|
||||
return 1
|
||||
fi
|
||||
rm -f "$REKEY_STATE"
|
||||
echo "$root_drive now opens with the password you entered, and the change is settled."
|
||||
}
|
||||
|
||||
change_selected_drive() {
|
||||
local encrypted_drives drive current old_slot others new_password confirmation status=0 system_drive=false
|
||||
|
||||
encrypted_drives=$(luks_drives)
|
||||
if [[ -z $encrypted_drives ]]; then
|
||||
echo "No encrypted drives available."
|
||||
return 1
|
||||
fi
|
||||
encrypted_drives=$(blkid -t TYPE=crypto_LUKS -o device)
|
||||
|
||||
if [[ -n $encrypted_drives ]]; then
|
||||
if (( $(wc -l <<<"$encrypted_drives") == 1 )); then
|
||||
drive="$encrypted_drives"
|
||||
drive_to_change="$encrypted_drives"
|
||||
else
|
||||
drive="$(omarchy-drive-select "$encrypted_drives")"
|
||||
drive_to_change="$(omarchy-drive-select "$encrypted_drives")"
|
||||
fi
|
||||
|
||||
if [[ -z $drive ]]; then
|
||||
if [[ -n $drive_to_change ]]; then
|
||||
new_password=$(gum input --password --header "New encryption password") || exit 1
|
||||
[[ -n $new_password ]] || { echo "Password cannot be empty."; exit 1; }
|
||||
|
||||
confirmation=$(gum input --password --header "Confirm new encryption password") || exit 1
|
||||
[[ $new_password == "$confirmation" ]] || { echo "Passwords do not match."; exit 1; }
|
||||
|
||||
echo "Changing full-disk encryption password for $drive_to_change"
|
||||
# The new key travels over stdin and reaches cryptsetup as a keyfile via
|
||||
# <(cat), leaving the tty free for the current-passphrase prompt.
|
||||
printf "%s" "$new_password" | sudo bash -c 'exec cryptsetup luksChangeKey --pbkdf argon2id --iter-time 2000 "$1" <(cat) </dev/tty' bash "$drive_to_change"
|
||||
else
|
||||
echo "No drive selected."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -n $root_drive && $(readlink -f "$drive") == "$root_drive" ]]; then
|
||||
system_drive=true
|
||||
drive=$root_drive
|
||||
fi
|
||||
|
||||
current=$(gum input --password --header "Current encryption password") || return 1
|
||||
old_slot=$(drive_key_slot "$drive" "$current") || status=$?
|
||||
if (( status == 2 )); then
|
||||
echo "That password does not open $drive."
|
||||
return 1
|
||||
elif (( status != 0 )); then
|
||||
echo "Could not check the password against $drive."
|
||||
return 1
|
||||
fi
|
||||
if [[ $system_drive == "true" ]]; then
|
||||
owner_slot_matches "$old_slot" || { echo "The system disk password did not change."; return 1; }
|
||||
# An interrupted change is finished from the one slot the password opened;
|
||||
# another copy of it would outlive the change.
|
||||
others=$(other_key_slots "$drive" "$current" "$old_slot") || { echo "Could not check the key slots of $drive."; return 1; }
|
||||
if [[ -n $others ]]; then
|
||||
echo "That password also opens key slot $others on $drive. Remove the extra copy first (sudo cryptsetup luksKillSlot $drive <slot>), then run omarchy-drive-password again."
|
||||
if [[ -n $(slot_recorder 2>/dev/null) ]]; then
|
||||
echo "Then record the slots you keep for the boot checks: sudo omarchy-lifecycle-dispatch luks-slots owner=<slot> recovery=<slot>, with an empty recovery= when no recovery key is left."
|
||||
fi
|
||||
echo "The system disk password did not change."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
new_password=$(gum input --password --header "New encryption password") || return 1
|
||||
[[ -n $new_password ]] || { echo "Password cannot be empty."; return 1; }
|
||||
|
||||
confirmation=$(gum input --password --header "Confirm new encryption password") || return 1
|
||||
[[ $new_password == "$confirmation" ]] || { echo "Passwords do not match."; return 1; }
|
||||
[[ $new_password != "$current" ]] || { echo "The new password is the current one."; return 1; }
|
||||
# Its slot is how the change is followed, so it must not open one already.
|
||||
status=0
|
||||
drive_key_slot "$drive" "$new_password" >/dev/null || status=$?
|
||||
if (( status == 0 )); then
|
||||
echo "That password already opens a key slot on $drive. Choose another."
|
||||
return 1
|
||||
elif (( status != 2 )); then
|
||||
echo "Could not check the password against $drive."
|
||||
return 1
|
||||
fi
|
||||
status=0
|
||||
|
||||
if [[ $system_drive == "true" ]]; then
|
||||
change_system_password "$current" "$new_password" "$old_slot"
|
||||
else
|
||||
change_luks_key "$drive" "$current" "$new_password" "$old_slot" || status=$?
|
||||
if (( status == 2 )); then
|
||||
echo "The password of $drive did not change."
|
||||
return 1
|
||||
elif (( status != 0 )); then
|
||||
echo "Could not confirm the password change on $drive."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
disk_lock=${REKEY_STATE%/*}/drive-password.disk.lock
|
||||
mkdir -p "${REKEY_STATE%/*}" && touch "$disk_lock" && exec {lock_fd}>"${REKEY_STATE%/*}/drive-password.lock" && flock -n "$lock_fd" ||
|
||||
{ echo "Another drive password change is running."; exit 1; }
|
||||
|
||||
unrecorded_owner=0
|
||||
root_drive=$(root_luks_device) || { echo "Could not tell which drive holds the system."; exit 1; }
|
||||
root_uuid=""
|
||||
if [[ -n $root_drive ]]; then
|
||||
root_uuid=$(lsblk -dno UUID "$root_drive") && [[ -n $root_uuid ]] || { echo "Could not read the UUID of $root_drive."; exit 1; }
|
||||
fi
|
||||
|
||||
if change_pending; then
|
||||
finish_interrupted_change
|
||||
else
|
||||
change_selected_drive
|
||||
echo "No encrypted drives available."
|
||||
exit 1
|
||||
fi
|
||||
@@ -30,14 +30,8 @@ if [[ -f ~/.config/alacritty/alacritty.toml ]]; then
|
||||
sed -i "s/family = \".*\"/family = \"$font_name\"/g" ~/.config/alacritty/alacritty.toml
|
||||
fi
|
||||
|
||||
if [[ -f ~/.config/kitty/kitty.conf ]] || omarchy-cmd-present kitty; then
|
||||
mkdir -p ~/.config/kitty
|
||||
if grep -qE '^[[:space:]]*font_family[[:space:]]+' ~/.config/kitty/kitty.conf 2>/dev/null; then
|
||||
kitty_font_name=$(printf '%s' "$font_name" | sed 's/[\\&/]/\\&/g')
|
||||
sed --follow-symlinks -i -E "s/^[[:space:]]*font_family[[:space:]]+.*/font_family $kitty_font_name/" ~/.config/kitty/kitty.conf
|
||||
else
|
||||
printf '\nfont_family %s\n' "$font_name" >>~/.config/kitty/kitty.conf
|
||||
fi
|
||||
if [[ -f ~/.config/kitty/kitty.conf ]]; then
|
||||
sed -i "s/^font_family .*/font_family $font_name/g" ~/.config/kitty/kitty.conf
|
||||
pkill -USR1 kitty
|
||||
fi
|
||||
|
||||
|
||||
@@ -13,12 +13,6 @@ for arg in "$@"; do
|
||||
esac
|
||||
done
|
||||
|
||||
# Resume is Limine-oriented and hibernate is unreliable on Asahi.
|
||||
if omarchy-hw-aarch64-apple; then
|
||||
echo "Skipping hibernation setup (not supported on Apple Silicon)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ! -f /sys/power/image_size ]]; then
|
||||
echo -e "Hibernation is not supported on your system" >&2
|
||||
exit 0
|
||||
@@ -36,35 +30,6 @@ MKINITCPIO_CONF="/etc/mkinitcpio.conf.d/omarchy_resume.conf"
|
||||
SWAP_FILE="/swap/swapfile"
|
||||
RESUME_DROP_IN="/etc/limine-entry-tool.d/resume.conf"
|
||||
|
||||
install_root_file() {
|
||||
local source="$1"
|
||||
local destination="$2"
|
||||
local mode="$3"
|
||||
local stage
|
||||
|
||||
stage=$(sudo /usr/bin/mktemp -- "${destination%/*}/.${destination##*/}.omarchy.XXXXXX") || return 1
|
||||
safe_stage_path "$stage" "$destination" || return 1
|
||||
|
||||
if sudo /usr/bin/install -m "$mode" -o root -g root -T "$source" "$stage" &&
|
||||
sudo /usr/bin/mv -Tf -- "$stage" "$destination"; then
|
||||
return 0
|
||||
else
|
||||
safe_stage_path "$stage" "$destination" && sudo /usr/bin/rm -f -- "$stage"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
safe_stage_path() {
|
||||
local stage="$1"
|
||||
local destination="$2"
|
||||
local prefix suffix
|
||||
|
||||
prefix="${destination%/*}/.${destination##*/}.omarchy."
|
||||
[[ $stage == "$prefix"* ]] || return 1
|
||||
suffix=${stage#"$prefix"}
|
||||
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
|
||||
}
|
||||
|
||||
# Check if hibernation is already configured
|
||||
if [[ -f $MKINITCPIO_CONF ]] && grep -q "^HOOKS+=(resume)$" "$MKINITCPIO_CONF"; then
|
||||
# Fix empty resume_offset if btrfs map-swapfile failed during initial setup
|
||||
@@ -118,20 +83,14 @@ if ! swapon --show | grep -q "$SWAP_FILE"; then
|
||||
sudo swapon -p 0 "$SWAP_FILE"
|
||||
fi
|
||||
|
||||
# Ensure keyboard backlight doesn't prevent sleep
|
||||
# Install this before writing the resume marker so a failed install remains
|
||||
# retryable through the normal setup command.
|
||||
if ! install_root_file "$OMARCHY_PATH/default/systemd/system-sleep/keyboard-backlight" \
|
||||
/usr/lib/systemd/system-sleep/keyboard-backlight 0755; then
|
||||
echo "Could not install the keyboard-backlight system-sleep hook" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Add resume hook to mkinitcpio
|
||||
sudo mkdir -p /etc/mkinitcpio.conf.d
|
||||
echo "Adding resume hook to $MKINITCPIO_CONF"
|
||||
echo "HOOKS+=(resume)" | sudo tee "$MKINITCPIO_CONF" >/dev/null
|
||||
|
||||
# Ensure keyboard backlight doesn't prevent sleep
|
||||
sudo cp -p "$OMARCHY_PATH/default/systemd/system-sleep/keyboard-backlight" /usr/lib/systemd/system-sleep/
|
||||
|
||||
# Add resume= kernel parameters so the initramfs resume hook knows where to find the
|
||||
# hibernation image. Without these, resume happens late (after GPU drivers load) and fails.
|
||||
if [[ ! -f $RESUME_DROP_IN ]]; then
|
||||
|
||||
@@ -11,18 +11,6 @@ if (( $# < 1 )); then
|
||||
fi
|
||||
|
||||
HOOK=$1
|
||||
|
||||
# Hook names are fixed labels chosen by Omarchy code (post-update, theme-set,
|
||||
# font-set). The name becomes a filename under the hooks directory. A slash
|
||||
# would turn it into directory levels, and a bare `.` or `..` would point bash
|
||||
# at the directory itself or its parent. Refuse those rather than follow them.
|
||||
# Dots inside a name (a..b) are fine; once slashes are out, only the whole
|
||||
# name being `.` or `..` can leave the directory.
|
||||
if [[ -z $HOOK || $HOOK == */* || $HOOK == "." || $HOOK == ".." ]]; then
|
||||
echo "Invalid hook name: $HOOK" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
HOOK_PATH="$HOME/.config/omarchy/hooks/$1"
|
||||
HOOK_DIR="$HOOK_PATH.d"
|
||||
shift
|
||||
|
||||
@@ -15,19 +15,8 @@ fi
|
||||
|
||||
HOOK_TYPE=$1
|
||||
HOOK_FILE=$2
|
||||
|
||||
# Hook types are the same labels omarchy-hook runs (post-update, theme-set).
|
||||
# The type becomes a directory under the hooks directory. A slash would turn
|
||||
# it into directory levels. A bare `.` or `..` is a name the runner already
|
||||
# refuses, so installing under it would write a hook nothing can run. Refuse
|
||||
# those rather than mkdir/cp into them. Dots inside a name (a..b) are fine.
|
||||
if [[ -z $HOOK_TYPE || $HOOK_TYPE == */* || $HOOK_TYPE == "." || $HOOK_TYPE == ".." ]]; then
|
||||
echo "Invalid hook name: $HOOK_TYPE" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
HOOK_DIR="$HOME/.config/omarchy/hooks/$HOOK_TYPE.d"
|
||||
HOOK_NAME=$(basename -- "$HOOK_FILE")
|
||||
HOOK_NAME=$(basename "$HOOK_FILE")
|
||||
HOOK_PATH="$HOOK_DIR/$HOOK_NAME"
|
||||
|
||||
if [[ ! -f $HOOK_FILE ]]; then
|
||||
@@ -36,7 +25,7 @@ if [[ ! -f $HOOK_FILE ]]; then
|
||||
fi
|
||||
|
||||
mkdir -p "$HOOK_DIR"
|
||||
cp -- "$HOOK_FILE" "$HOOK_PATH"
|
||||
cp "$HOOK_FILE" "$HOOK_PATH"
|
||||
chmod 755 "$HOOK_PATH"
|
||||
|
||||
echo "Installed $HOOK_TYPE hook: $HOOK_PATH"
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Detect whether the CPU is aarch64 (every ARM machine, Apple Silicon included).
|
||||
|
||||
[[ $(uname -m) == "aarch64" ]]
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/bin/bash -p
|
||||
|
||||
# omarchy:summary=Detect whether the computer is an Apple Silicon Mac.
|
||||
|
||||
# Callers run this by absolute path, from systemd and from the shell, so use the
|
||||
# detector shipped beside it rather than whichever one PATH finds. -p keeps a
|
||||
# root caller's exported functions out, so a launch without it is refused, and
|
||||
# nothing here searches PATH.
|
||||
if [[ $- != *p* ]]; then
|
||||
echo "Refusing an unsafe Bash startup." >&2
|
||||
exit 126
|
||||
fi
|
||||
|
||||
dir=${BASH_SOURCE[0]%/*}
|
||||
[[ $dir != "${BASH_SOURCE[0]}" ]] || dir=.
|
||||
platform=$("$dir/omarchy-hw-platform") || exit 1
|
||||
[[ $platform == "aarch64-apple" ]]
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/bin/bash -p
|
||||
|
||||
# omarchy:summary=Detect whether the computer is an Apple Silicon Mac (the old name of omarchy-hw-aarch64-apple).
|
||||
# omarchy:hidden=true
|
||||
|
||||
# Kept while omarchy-mac's packages and units still call it by this name. Like
|
||||
# the predicate it runs, it refuses a launch without -p and searches no PATH.
|
||||
if [[ $- != *p* ]]; then
|
||||
echo "Refusing an unsafe Bash startup." >&2
|
||||
exit 126
|
||||
fi
|
||||
|
||||
dir=${BASH_SOURCE[0]%/*}
|
||||
[[ $dir != "${BASH_SOURCE[0]}" ]] || dir=.
|
||||
exec "$dir/omarchy-hw-aarch64-apple"
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Match the Dell XPS 13 DX13260 with an Intel Panther Lake GPU.
|
||||
|
||||
omarchy-hw-match "DX13260" && omarchy-hw-intel-ptl
|
||||
+6
-40
@@ -5,48 +5,14 @@
|
||||
|
||||
backlight_path="${OMARCHY_BACKLIGHT_PATH:-/sys/class/backlight}"
|
||||
|
||||
# The platform package may name backlights to skip and to prefer (see
|
||||
# docs/file-layout.md).
|
||||
displays_conf=/usr/share/omarchy-platform/displays.conf
|
||||
skips=()
|
||||
prefers=()
|
||||
if [[ -r $displays_conf ]]; then
|
||||
# \r in IFS: a file saved with CRLF line ends reads the same.
|
||||
while IFS=$' \t\r' read -r directive argument extra || [[ -n $directive ]]; do
|
||||
[[ -n $argument && -z $extra && $argument != */* && $argument != . && $argument != .. ]] || continue
|
||||
case $directive in
|
||||
backlight-skip) skips+=("$argument") ;;
|
||||
backlight-prefer) prefers+=("$argument") ;;
|
||||
esac
|
||||
done <"$displays_conf"
|
||||
fi
|
||||
|
||||
usable() {
|
||||
local skip
|
||||
# The Touch Bar on T2 Macs registers a backlight that never drives the display panel.
|
||||
[[ -n $1 && $1 != appletb_backlight ]] || return 1
|
||||
for skip in "${skips[@]}"; do
|
||||
# shellcheck disable=SC2053 # a skip is a glob
|
||||
[[ $1 == $skip ]] && return 1
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# Start with the first possible output, then refine to the most likely given an order heuristic.
|
||||
device=""
|
||||
while IFS= read -r name; do
|
||||
if usable "$name"; then
|
||||
device="$name"
|
||||
break
|
||||
fi
|
||||
done < <(ls -1 "$backlight_path" 2>/dev/null)
|
||||
|
||||
# gmux comes first: apple-gmux only registers when the kernel has already picked it, and on
|
||||
# dual-GPU Macs the GPU's own PWM stops driving the panel once that GPU suspends. The
|
||||
# platform's preferred backlights follow, then the GPU and ACPI ones.
|
||||
# Glob the candidates in the loop list: [[ ]] does not do pathname expansion.
|
||||
for candidate in "$backlight_path"/gmux_backlight "${prefers[@]/#/$backlight_path/}" "$backlight_path"/amdgpu_bl* "$backlight_path"/intel_backlight "$backlight_path"/acpi_video*; do
|
||||
if [[ -e $candidate ]] && usable "${candidate##*/}"; then
|
||||
# The Touch Bar on T2 Macs registers a backlight that never drives the display panel.
|
||||
device="$(ls -1 "$backlight_path" 2>/dev/null | grep -vx appletb_backlight | head -n1)"
|
||||
# gmux comes first: apple-gmux only registers when the kernel has already picked it, and on
|
||||
# dual-GPU Macs the GPU's own PWM stops driving the panel once that GPU suspends.
|
||||
for candidate in "$backlight_path"/gmux_backlight "$backlight_path"/amdgpu_bl* "$backlight_path"/intel_backlight "$backlight_path"/acpi_video*; do
|
||||
if [[ -e $candidate ]]; then
|
||||
device="${candidate##*/}"
|
||||
break
|
||||
fi
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Detect whether an Elgato Cam Link 4K is plugged in.
|
||||
|
||||
grep -qsx "Cam Link 4K" /sys/bus/usb/devices/*/product
|
||||
+2
-26
@@ -2,39 +2,15 @@
|
||||
|
||||
# omarchy:summary=Returns true when running on a laptop (has a lid or laptop chassis).
|
||||
|
||||
acpi_lid="${OMARCHY_ACPI_LID_PATH:-/proc/acpi/button/lid}"
|
||||
input_class="${OMARCHY_INPUT_CLASS_PATH:-/sys/class/input}"
|
||||
dmi_chassis="${OMARCHY_DMI_CHASSIS_TYPE_PATH:-/sys/class/dmi/id/chassis_type}"
|
||||
|
||||
shopt -s nullglob
|
||||
acpi_states=("$acpi_lid"/*/state)
|
||||
input_switches=("$input_class"/*/capabilities/sw)
|
||||
shopt -u nullglob
|
||||
|
||||
# A lid switch is the definitive signal for clamshell-capable hardware.
|
||||
for state in "${acpi_states[@]}"; do
|
||||
for state in /proc/acpi/button/lid/*/state; do
|
||||
[[ -e $state ]] && exit 0
|
||||
done
|
||||
|
||||
# Without an ACPI lid (Apple Silicon, for one), any input device that reports a
|
||||
# lid switch (SW_LID, bit 0 of its switch capabilities, the last word of the
|
||||
# bitmap) counts. The switch's name varies: Apple's SMC input device carries
|
||||
# the same name on a Mac mini, where it reports no lid.
|
||||
for switches in "${input_switches[@]}"; do
|
||||
[[ -r $switches ]] || continue
|
||||
read -r -a words <"$switches" || true
|
||||
(( ${#words[@]} )) || continue
|
||||
word=${words[-1]}
|
||||
[[ $word =~ ^[0-9a-fA-F]+$ ]] || continue
|
||||
(( 16#$word & 1 )) && exit 0
|
||||
done
|
||||
|
||||
# Fall back to the DMI chassis type for laptops that don't expose an ACPI lid
|
||||
# button. 8=Portable 9=Laptop 10=Notebook 14=Sub Notebook 30=Tablet
|
||||
# 31=Convertible 32=Detachable.
|
||||
chassis_type=""
|
||||
read -r chassis_type <"$dmi_chassis" 2>/dev/null || true
|
||||
case $chassis_type in
|
||||
case $(< /sys/class/dmi/id/chassis_type 2>/dev/null) in
|
||||
8 | 9 | 10 | 14 | 30 | 31 | 32) exit 0 ;;
|
||||
esac
|
||||
|
||||
|
||||
@@ -3,45 +3,9 @@
|
||||
# omarchy:summary=Returns true when the laptop lid is closed
|
||||
# omarchy:hidden=true
|
||||
|
||||
acpi_lid="${OMARCHY_ACPI_LID_PATH:-/proc/acpi/button/lid}"
|
||||
input_class="${OMARCHY_INPUT_CLASS_PATH:-/sys/class/input}"
|
||||
|
||||
shopt -s nullglob
|
||||
acpi_states=("$acpi_lid"/*/state)
|
||||
input_switches=("$input_class"/*/capabilities/sw)
|
||||
shopt -u nullglob
|
||||
|
||||
# An input device that reports a lid switch: SW_LID, bit 0 of the last word
|
||||
# of its switch bitmap.
|
||||
has_lid_switch() {
|
||||
local switches word
|
||||
local -a words
|
||||
|
||||
for switches in "${input_switches[@]}"; do
|
||||
[[ -r $switches ]] || continue
|
||||
read -r -a words <"$switches" || true
|
||||
(( ${#words[@]} )) || continue
|
||||
word=${words[-1]}
|
||||
[[ $word =~ ^[0-9a-fA-F]+$ ]] || continue
|
||||
(( 16#$word & 1 )) && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if (( ${#acpi_states[@]} )); then
|
||||
# An ACPI lid reports its own state.
|
||||
for state in "${acpi_states[@]}"; do
|
||||
[[ -r $state ]] || continue
|
||||
[[ $(<"$state") == *"closed"* ]] && exit 0
|
||||
done
|
||||
elif has_lid_switch; then
|
||||
# Elsewhere (Apple Silicon's SMC, for one) only logind reads the switch. Ask
|
||||
# it only then: it answers "open" when it tracks no lid. This runs in sudo's
|
||||
# and polkit's PAM stack where fingerprint login is set up, so a stalled
|
||||
# logind must not hold up every sudo.
|
||||
lid_state=$(busctl --timeout=2 get-property org.freedesktop.login1 \
|
||||
/org/freedesktop/login1 org.freedesktop.login1.Manager LidClosed 2>/dev/null)
|
||||
[[ $lid_state == "b true" ]] && exit 0
|
||||
fi
|
||||
for state in /proc/acpi/button/lid/*/state; do
|
||||
[[ -r $state ]] || continue
|
||||
[[ $(< "$state") == *"closed"* ]] && exit 0
|
||||
done
|
||||
|
||||
exit 1
|
||||
@@ -3,5 +3,5 @@
|
||||
# omarchy:summary=Match against the computer's DMI product name or product family (case-insensitive).
|
||||
# omarchy:args=<pattern>
|
||||
|
||||
grep -qi -- "$1" /sys/class/dmi/id/product_name 2>/dev/null ||
|
||||
grep -qi -- "$1" /sys/class/dmi/id/product_family 2>/dev/null
|
||||
grep -qi "$1" /sys/class/dmi/id/product_name 2>/dev/null ||
|
||||
grep -qi "$1" /sys/class/dmi/id/product_family 2>/dev/null
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Detect whether NVIDIA drives the display (rather than a hybrid iGPU).
|
||||
|
||||
# On hybrid laptops the display is usually wired to the integrated GPU while
|
||||
# the NVIDIA card only handles offloaded rendering. Forcing NVIDIA's VA-API and
|
||||
# GLX drivers globally then breaks Chromium-based browsers (black video with
|
||||
# audio still playing), so the Hyprland NVIDIA env only applies when NVIDIA is
|
||||
# the display GPU.
|
||||
#
|
||||
# sysfs exposes which VGA device drives the display via boot_vga. When no
|
||||
# device claims it (attribute missing or unreadable), assume NVIDIA is the
|
||||
# display GPU to preserve the current behavior on machines we cannot tell.
|
||||
#
|
||||
# Read the cached sysfs IDs rather than lspci, which reads PCI config space and
|
||||
# resumes runtime-suspended GPUs.
|
||||
pci_devices_path="${OMARCHY_PCI_DEVICES_PATH:-/sys/bus/pci/devices}"
|
||||
|
||||
shopt -s nullglob
|
||||
|
||||
nvidia_boot=0
|
||||
other_boot=0
|
||||
|
||||
for device in "$pci_devices_path"/*; do
|
||||
[[ $(< "$device/class") == 0x03* ]] || continue
|
||||
boot_vga=0
|
||||
if [[ -f $device/boot_vga ]]; then
|
||||
boot_vga=$(< "$device/boot_vga")
|
||||
fi
|
||||
if [[ $(< "$device/vendor") == "0x10de" ]]; then
|
||||
if (( boot_vga == 1 )); then
|
||||
nvidia_boot=1
|
||||
fi
|
||||
else
|
||||
if (( boot_vga == 1 )); then
|
||||
other_boot=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if (( other_boot == 1 && nvidia_boot == 0 )); then
|
||||
exit 1
|
||||
else
|
||||
exit 0
|
||||
fi
|
||||
@@ -1,202 +0,0 @@
|
||||
#!/bin/bash -p
|
||||
|
||||
# omarchy:summary=Print the hardware platform: x86, aarch64 or aarch64-apple.
|
||||
|
||||
# Platform names are the CPU architecture, then the hardware family when it
|
||||
# needs handling of its own: x86, aarch64 (every ARM machine without one) and
|
||||
# aarch64-apple (Apple Silicon). A later family is aarch64-<family>, so
|
||||
# "aarch64*" always means ARM (AGENTS.md, Platforms).
|
||||
#
|
||||
# ARM laptops have no DMI, so the board identity is the root node of the device
|
||||
# tree the firmware hands the kernel: "apple,<model>" from Asahi's m1n1 on every
|
||||
# Apple Silicon Mac, "qcom,<soc>" on Snapdragon laptops, which are plain aarch64
|
||||
# here and must never pass for a Mac. Each compatible string is a NUL-separated
|
||||
# list, and only a token's vendor prefix counts.
|
||||
#
|
||||
# An image is built away from the machine it will run on, so while this root is
|
||||
# being built rather than booted, the image-target manifest the builder wrote
|
||||
# names the platform and the build host's device tree is never read.
|
||||
#
|
||||
# Fixture roots let unprivileged tests fake the device tree, PID 1 and the image
|
||||
# root. Naming any of them makes the whole view a fixture: nothing live is read,
|
||||
# not even the device tree when only the image root is named. A privileged
|
||||
# caller must not let its environment pick the platform at all.
|
||||
# Privileged mode (-p) keeps exported functions and BASH_ENV out of this shell,
|
||||
# so a launch without it is refused. Root then starts over in an empty
|
||||
# environment and reads only the live paths with a fixed PATH.
|
||||
if [[ $- != *p* ]]; then
|
||||
echo "Refusing an unsafe Bash startup." >&2
|
||||
exit 126
|
||||
fi
|
||||
|
||||
if (( EUID == 0 )) && [[ ${1:-} != --clean-environment ]]; then
|
||||
exec /usr/bin/env -i PATH=/usr/bin /bin/bash -p -- "${BASH_SOURCE[0]}" --clean-environment "$@"
|
||||
fi
|
||||
|
||||
if (( EUID == 0 )); then
|
||||
export PATH=/usr/bin
|
||||
fixtures=""
|
||||
else
|
||||
fixtures=${OMARCHY_PROC_ROOT:-}${OMARCHY_SYS_ROOT:-}${OMARCHY_IMAGE_ROOT:-}
|
||||
fi
|
||||
|
||||
if [[ -z $fixtures ]]; then
|
||||
proc=/proc
|
||||
sources=(/proc/device-tree/compatible /sys/firmware/devicetree/base/compatible)
|
||||
image_root=""
|
||||
image_dir=/var/lib/omarchy/image
|
||||
image_owner=0
|
||||
else
|
||||
proc=${OMARCHY_PROC_ROOT:-}
|
||||
sources=()
|
||||
[[ -z ${OMARCHY_PROC_ROOT:-} ]] || sources+=("$OMARCHY_PROC_ROOT/device-tree/compatible")
|
||||
[[ -z ${OMARCHY_SYS_ROOT:-} ]] || sources+=("$OMARCHY_SYS_ROOT/firmware/devicetree/base/compatible")
|
||||
image_root=${OMARCHY_IMAGE_ROOT:-}
|
||||
image_dir=${image_root:+$image_root/var/lib/omarchy/image}
|
||||
image_owner=$EUID
|
||||
fi
|
||||
manifest=$image_dir/target
|
||||
|
||||
contradiction() {
|
||||
echo "Error: contradictory platform identity: $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ! machine=$(uname -m) || [[ -z $machine ]]; then
|
||||
echo "Error: cannot read the CPU architecture" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A booted system is one systemd runs as PID 1 from this root. An image build
|
||||
# is told apart by what it shows: no /run/systemd/system (a root with a /run of
|
||||
# its own), PID 1's root being another (a chroot on the build host, even with
|
||||
# the host's /run bound in, as arch-chroot does), or PID 1 being something other
|
||||
# than systemd (a PID namespace whose PID 1 is the build). What cannot be seen,
|
||||
# such as PID 1's root to a normal user or /proc/1 behind hidepid, never makes a
|
||||
# build: a booted system always answers from its hardware, so a build runs as
|
||||
# root. A caller in a private PID namespace looks like a build too, so nothing
|
||||
# that asks the detector runs with PrivatePIDs=.
|
||||
booted() {
|
||||
local comm
|
||||
|
||||
[[ -d $image_root/run/systemd/system ]] || return 1
|
||||
if [[ -n $proc && -e $proc/1/root ]]; then
|
||||
[[ $proc/1/root -ef $image_root/ ]] || return 1
|
||||
fi
|
||||
if [[ -n $proc && -r $proc/1/comm ]]; then
|
||||
read -r comm <"$proc/1/comm" && [[ $comm == "systemd" ]] || return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Owned by root (the test's own user for a fixture), not a symlink, and not
|
||||
# writable by anyone else.
|
||||
trusted() {
|
||||
local owner mode
|
||||
|
||||
[[ -e $1 && ! -L $1 ]] || return 1
|
||||
read -r owner mode < <(stat -c '%u %a' -- "$1") || return 1
|
||||
[[ $owner =~ ^[0-9]+$ && $mode =~ ^[0-7]+$ ]] && (( owner == image_owner && (8#$mode & 8#022) == 0 ))
|
||||
}
|
||||
|
||||
# The manifest: format=1, platform= one of the platform names, comments and
|
||||
# unknown keys ignored, and any other line refused. Image builders written before
|
||||
# the names settled say apple-silicon, generic-aarch64 or generic, which still
|
||||
# read as aarch64-apple, aarch64 and x86. The detector reads it itself, so it
|
||||
# stands alone wherever a package ships a copy of it.
|
||||
image_platform() {
|
||||
local line key value format="" platform=""
|
||||
|
||||
if [[ ! -d $image_dir || ! -f $manifest ]] || ! trusted "$image_dir" || ! trusted "$manifest"; then
|
||||
echo "Error: the image manifest $manifest is not a root-owned regular file" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
while IFS= read -r line || [[ -n $line ]]; do
|
||||
[[ -n $line && $line != \#* ]] || continue
|
||||
if [[ $line != *=* ]]; then
|
||||
echo "Error: the image manifest $manifest is malformed: $line" >&2
|
||||
return 1
|
||||
fi
|
||||
key=${line%%=*}
|
||||
value=${line#*=}
|
||||
case $key in
|
||||
format) format=$value ;;
|
||||
platform) platform=$value ;;
|
||||
esac
|
||||
done <"$manifest"
|
||||
|
||||
if [[ $format != "1" ]]; then
|
||||
echo "Error: the image manifest $manifest is not format=1" >&2
|
||||
return 1
|
||||
fi
|
||||
case $platform in
|
||||
apple-silicon) platform=aarch64-apple ;;
|
||||
generic-aarch64) platform=aarch64 ;;
|
||||
generic) platform=x86 ;;
|
||||
esac
|
||||
if [[ ! $platform =~ ^(x86|aarch64|aarch64-apple)$ ]]; then
|
||||
echo "Error: the image manifest $manifest names no known platform: ${platform:-none}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$platform"
|
||||
}
|
||||
|
||||
if [[ -n $image_dir ]] && [[ -e $manifest || -L $manifest ]] && ! booted; then
|
||||
platform=$(image_platform) || exit 1
|
||||
if [[ $platform == "x86" ]]; then
|
||||
[[ $machine != "aarch64" ]] || contradiction "$manifest names $platform hardware but the CPU is $machine"
|
||||
else
|
||||
[[ $machine == "aarch64" ]] || contradiction "$manifest names $platform hardware but the CPU is $machine"
|
||||
fi
|
||||
echo "$platform"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
vendor=""
|
||||
vendor_source=""
|
||||
for source in "${sources[@]}"; do
|
||||
[[ -r $source ]] || continue
|
||||
|
||||
apple=0
|
||||
qcom=0
|
||||
mapfile -d '' -t tokens <"$source"
|
||||
for token in "${tokens[@]}"; do
|
||||
[[ $token == "apple,"* ]] && apple=1
|
||||
[[ $token == "qcom,"* ]] && qcom=1
|
||||
done
|
||||
|
||||
if (( apple && qcom )); then
|
||||
contradiction "$source names both Apple and Qualcomm hardware"
|
||||
elif (( apple )); then
|
||||
found=apple
|
||||
elif (( qcom )); then
|
||||
found=qcom
|
||||
else
|
||||
found=none
|
||||
fi
|
||||
|
||||
if [[ -n $vendor && $vendor != "$found" ]]; then
|
||||
contradiction "$vendor_source says $vendor but $source says $found"
|
||||
fi
|
||||
vendor=$found
|
||||
vendor_source=$source
|
||||
done
|
||||
|
||||
case $vendor in
|
||||
apple | qcom)
|
||||
[[ $machine == "aarch64" ]] || contradiction "$vendor_source names $vendor hardware but the CPU is $machine"
|
||||
if [[ $vendor == "apple" ]]; then
|
||||
echo aarch64-apple
|
||||
else
|
||||
echo aarch64
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
if [[ $machine == "aarch64" ]]; then
|
||||
echo aarch64
|
||||
else
|
||||
echo x86
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
+1
-26
@@ -2,30 +2,5 @@
|
||||
|
||||
# omarchy:summary=Print the detected Hyprland touchpad or trackpad device name
|
||||
|
||||
mice=$(hyprctl devices -j | jq -r '.mice[].name')
|
||||
device=$(grep -iE -m1 'touchpad|trackpad' <<<"$mice")
|
||||
|
||||
# Some touchpads carry neither word (Apple Silicon's is "apple-mtp-multi-touch"),
|
||||
# so ask udev which mouse is one. The name alone can't say: some touchscreens'
|
||||
# mouse interfaces are called multi-touch too.
|
||||
if [[ -z $device ]]; then
|
||||
input_class="${OMARCHY_INPUT_CLASS_PATH:-/sys/class/input}"
|
||||
for event in "$input_class"/event*; do
|
||||
[[ -r $event/device/name ]] || continue
|
||||
# Hyprland's name for a device: the kernel's, lowercased, with spaces,
|
||||
# commas and newlines as dashes, and -1, -2 and so on after a name another
|
||||
# device already took.
|
||||
name=$(<"$event/device/name")
|
||||
name=${name,,}
|
||||
name=${name//[ ,$'\n']/-}
|
||||
udevadm info --query=property "$event" 2>/dev/null | grep -Fxq 'ID_INPUT_TOUCHPAD=1' || continue
|
||||
while IFS= read -r mouse; do
|
||||
if [[ $mouse == "$name" || ( $mouse == "$name"-* && ${mouse#"$name"-} =~ ^[0-9]+$ ) ]]; then
|
||||
device=$mouse
|
||||
break 2
|
||||
fi
|
||||
done <<<"$mice"
|
||||
done
|
||||
fi
|
||||
|
||||
device=$(hyprctl devices -j | jq -r '[.mice[] | .name | select(test("touchpad|trackpad"; "i"))] | first // empty')
|
||||
[[ -n $device ]] && echo "$device"
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Returns true when running in a virtual machine.
|
||||
|
||||
systemd-detect-virt --vm --quiet
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Detect whether the CPU is x86_64.
|
||||
|
||||
[[ $(uname -m) == "x86_64" ]]
|
||||
@@ -10,7 +10,6 @@ sync_clamshell() {
|
||||
(
|
||||
flock -n 9 || exit 0
|
||||
omarchy-hyprland-monitor-clamshell
|
||||
omarchy-system-lid-inhibit
|
||||
) 9>"$LOCK"
|
||||
}
|
||||
|
||||
|
||||
@@ -2,18 +2,6 @@
|
||||
|
||||
# omarchy:summary=Toggles transparency for the currently focused window.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
addr=$(hyprctl activewindow -j | jq -r '.address // empty')
|
||||
[[ -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
|
||||
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 || \
|
||||
hyprctl dispatch setprop "address:$addr" opaque toggle
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Configure, inspect, or switch input methods
|
||||
# omarchy:args=<status|cycle [back]|configure --seed|--defaults>
|
||||
# omarchy:hidden=true
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ${1:-} == "cycle" ]]; then
|
||||
direction=${2:-next}
|
||||
if [[ $direction != "next" && $direction != "back" ]]; then
|
||||
echo "Usage: omarchy-input-method cycle [back]" >&2
|
||||
exit 1
|
||||
fi
|
||||
# The shell's reader retains a choice made while nothing has focus.
|
||||
if [[ $(omarchy-shell shell cycleInput "$direction") != "ok" ]]; then
|
||||
echo "The shell's input reader is not running" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
/usr/bin/python3 "$OMARCHY_PATH/default/input-methods/configure.py" "$@"
|
||||
fi
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
@@ -8,8 +8,6 @@ set -e
|
||||
|
||||
source "$OMARCHY_PATH/install/helpers/browser-policy.sh"
|
||||
|
||||
opt_path="${OMARCHY_OPT_PATH:-/opt}"
|
||||
|
||||
setup_chromium_policy_directory() {
|
||||
browser_policy_setup_dir "$1"
|
||||
}
|
||||
@@ -19,17 +17,6 @@ announce_browser_installed() {
|
||||
echo "$1 browser installed. Make it the default via Setup > Defaults > Browser."
|
||||
}
|
||||
|
||||
# The widevine package registers its CDM for Chromium and Firefox only; Brave
|
||||
# looks for one in its own install directory.
|
||||
link_widevine_cdm() {
|
||||
local cdm="$opt_path/WidevineCdm/chromium"
|
||||
local install_dir="$opt_path/$1"
|
||||
|
||||
if [[ -d $cdm && -d $install_dir && ! -e $install_dir/WidevineCdm ]]; then
|
||||
as_root ln -sfn "$cdm" "$install_dir/WidevineCdm"
|
||||
fi
|
||||
}
|
||||
|
||||
copy_chromium_flags() {
|
||||
mkdir -p ~/.config
|
||||
cp -f "$OMARCHY_PATH/config/chromium-flags.conf" "$1"
|
||||
@@ -50,7 +37,6 @@ chromium)
|
||||
setup_chromium_policy_directory /etc/chromium/policies/managed
|
||||
copy_chromium_flags ~/.config/chromium-flags.conf
|
||||
omarchy-theme-set-browser
|
||||
omarchy-lifecycle-dispatch post-install chromium
|
||||
announce_browser_installed "Chromium"
|
||||
;;
|
||||
chrome)
|
||||
@@ -60,7 +46,6 @@ chrome)
|
||||
setup_chromium_policy_directory /etc/opt/chrome/policies/managed
|
||||
copy_chromium_flags ~/.config/chrome-flags.conf
|
||||
omarchy-theme-set-browser
|
||||
omarchy-lifecycle-dispatch post-install chrome
|
||||
announce_browser_installed "Chrome"
|
||||
;;
|
||||
edge)
|
||||
@@ -70,7 +55,6 @@ edge)
|
||||
setup_chromium_policy_directory /etc/opt/edge/policies/managed
|
||||
copy_chromium_flags ~/.config/microsoft-edge-stable-flags.conf
|
||||
omarchy-theme-set-browser
|
||||
omarchy-lifecycle-dispatch post-install edge
|
||||
announce_browser_installed "Edge"
|
||||
;;
|
||||
brave)
|
||||
@@ -79,9 +63,7 @@ brave)
|
||||
|
||||
setup_chromium_policy_directory /etc/brave/policies/managed
|
||||
copy_chromium_flags ~/.config/brave-flags.conf
|
||||
link_widevine_cdm brave-bin
|
||||
omarchy-theme-set-browser
|
||||
omarchy-lifecycle-dispatch post-install brave
|
||||
announce_browser_installed "Brave"
|
||||
;;
|
||||
brave-origin)
|
||||
@@ -90,9 +72,7 @@ brave-origin)
|
||||
|
||||
setup_chromium_policy_directory /etc/brave/policies/managed
|
||||
copy_chromium_flags ~/.config/brave-origin-flags.conf
|
||||
link_widevine_cdm brave-origin-bin
|
||||
omarchy-theme-set-browser
|
||||
omarchy-lifecycle-dispatch post-install brave-origin
|
||||
announce_browser_installed "Brave Origin"
|
||||
;;
|
||||
firefox)
|
||||
@@ -101,7 +81,6 @@ firefox)
|
||||
|
||||
browser_policy_setup_firefox_distribution /usr/lib/firefox/distribution
|
||||
setup_firefox_wayland
|
||||
omarchy-lifecycle-dispatch post-install firefox
|
||||
announce_browser_installed "Firefox"
|
||||
;;
|
||||
zen)
|
||||
@@ -110,7 +89,6 @@ zen)
|
||||
|
||||
browser_policy_setup_firefox_distribution /opt/zen-browser/distribution
|
||||
setup_firefox_wayland
|
||||
omarchy-lifecycle-dispatch post-install zen
|
||||
announce_browser_installed "Zen"
|
||||
;;
|
||||
*)
|
||||
|
||||
@@ -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
|
||||
@@ -16,9 +16,6 @@ browser_dirs=(
|
||||
"$HOME/.config/BraveSoftware/Brave-Browser"
|
||||
"$HOME/.config/BraveSoftware/Brave-Browser-Beta"
|
||||
"$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-dev"
|
||||
)
|
||||
|
||||
@@ -17,9 +17,6 @@ browser_dirs=(
|
||||
"$HOME/.config/BraveSoftware/Brave-Browser"
|
||||
"$HOME/.config/BraveSoftware/Brave-Browser-Beta"
|
||||
"$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-dev"
|
||||
)
|
||||
|
||||
@@ -12,10 +12,37 @@ if [[ -z $1 ]]; then
|
||||
fi
|
||||
|
||||
install_php() {
|
||||
mise tool-alias set php github:nunomaduro/static-php-builds
|
||||
mise use --global php@latest
|
||||
omarchy-pkg-add php composer php-sqlite xdebug
|
||||
|
||||
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() {
|
||||
@@ -57,7 +84,7 @@ laravel)
|
||||
echo -e "Installing PHP and Laravel...\n"
|
||||
install_php
|
||||
install_node
|
||||
mise x php -- composer global require laravel/installer
|
||||
composer global require laravel/installer
|
||||
echo -e "\nYou can now run: laravel new myproject"
|
||||
;;
|
||||
symfony)
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Install and configure Superwhisper dictation
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
set -e
|
||||
|
||||
omarchy-pkg-add superwhisper-bin
|
||||
bash /usr/share/superwhisper/setup-user
|
||||
# Existing profiles may still use Right Alt, which conflicts with Omarchy.
|
||||
# Keep a recording shortcut active while moving away from that binding.
|
||||
shortcuts=$(superwhisper shortcuts show)
|
||||
if [[ $(jq -r '.shortcuts.hold' <<< "$shortcuts") == "RightAlt" ]]; then
|
||||
if [[ -z $(jq -r '.shortcuts.toggle' <<< "$shortcuts") ]]; then
|
||||
superwhisper shortcuts set hold "RightSuper" >/dev/null
|
||||
else
|
||||
superwhisper shortcuts set hold none >/dev/null
|
||||
fi
|
||||
fi
|
||||
# Superwhisper requires a native recording shortcut; keep it separate from
|
||||
# Omarchy's push-to-talk keys.
|
||||
superwhisper shortcuts set toggle "Alt+Space" >/dev/null
|
||||
superwhisper shortcuts set hold none >/dev/null
|
||||
omarchy-dictation-use superwhisper
|
||||
hyprctl reload >/dev/null
|
||||
omarchy-notification-send -g "Superwhisper Dictation Ready" "Hold Right Alt or F9 to dictate (or toggle with Super + Ctrl + X)." -t 10000
|
||||
superwhisper settings
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Install and configure Voxtype dictation
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
set -e
|
||||
|
||||
voxtype_lib_path="${OMARCHY_VOXTYPE_LIB_PATH:-/usr/lib/voxtype}"
|
||||
|
||||
if omarchy-cmd-missing voxtype; then
|
||||
if gum confirm "Install Voxtype + AI model (~150MB) to enable dictation?"; then
|
||||
omarchy-pkg-add wtype voxtype-bin
|
||||
else
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
config_home="${XDG_CONFIG_HOME:-$HOME/.config}"
|
||||
mkdir -p "$config_home/voxtype"
|
||||
if [[ ! -e $config_home/voxtype/config.toml ]]; then
|
||||
cp "$OMARCHY_PATH/default/voxtype/config.toml" "$config_home/voxtype/"
|
||||
# A Vulkan runtime is not enough: aarch64 voxtype-bin ships no Vulkan backend.
|
||||
if omarchy-hw-vulkan && [[ -x $voxtype_lib_path/voxtype-vulkan ]]; then
|
||||
voxtype setup gpu --enable || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Setup reuses the configured model and downloads it only when missing.
|
||||
# Retry these steps even when an earlier attempt already installed the binary.
|
||||
voxtype setup --download --no-post-install
|
||||
voxtype setup systemd
|
||||
systemctl --user enable --now voxtype.service
|
||||
|
||||
omarchy-dictation-use voxtype
|
||||
hyprctl reload >/dev/null
|
||||
omarchy-notification-send -g "Voxtype Dictation Ready" "Hold Right Alt or F9 to dictate (or toggle with Super + Ctrl + X)." -t 10000
|
||||
@@ -27,6 +27,4 @@ elif omarchy-hw-nvidia-without-gsp; then
|
||||
PACKAGES+=(lib32-nvidia-580xx-utils)
|
||||
fi
|
||||
|
||||
if (( ${#PACKAGES[@]} > 0 )); then
|
||||
omarchy-pkg-add "${PACKAGES[@]}"
|
||||
fi
|
||||
(( ${#PACKAGES[@]} > 0 )) && omarchy-pkg-add "${PACKAGES[@]}"
|
||||
@@ -6,16 +6,8 @@
|
||||
set -e
|
||||
|
||||
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-lifecycle-dispatch post-install steam
|
||||
omarchy-install-gaming-gpu-lib32
|
||||
|
||||
echo ""
|
||||
echo "Steam will start automatically now. This might take a while..."
|
||||
|
||||
@@ -10,7 +10,7 @@ set -e
|
||||
echo "Installing Xbox controller Bluetooth support..."
|
||||
|
||||
# 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
|
||||
echo blacklist xpad | sudo tee /etc/modprobe.d/blacklist-xpad.conf >/dev/null
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -21,11 +21,10 @@ if gum confirm "Are you sure you want to restore all preinstalled web apps, TUI
|
||||
obs-studio \
|
||||
kdenlive \
|
||||
moonlight-qt \
|
||||
lazydocker \
|
||||
omacut \
|
||||
monologue \
|
||||
omacalc \
|
||||
omawrite \
|
||||
hype; then
|
||||
omawrite; then
|
||||
echo -e "\nPreinstalls are still marked as removed. Fix the errors above and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -28,7 +28,7 @@ echo "Installing 1Password extension for Chromium..."
|
||||
install_chromium_extension
|
||||
|
||||
echo "Opening 1Password..."
|
||||
setsid uwsm-app -- 1password >/dev/null 2>&1 &
|
||||
uwsm-app -- 1password >/dev/null 2>&1 &
|
||||
|
||||
echo ""
|
||||
echo "1Password has been installed. Restart Chromium to load the browser extension."
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Install and start the Dropbox service. Must then be authenticated via the web.
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
echo "Installing all dependencies..."
|
||||
omarchy-pkg-add dropbox dropbox-cli libappindicator-gtk3 python-gpgme nautilus-dropbox
|
||||
|
||||
@@ -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)"
|
||||
@@ -4,11 +4,8 @@
|
||||
|
||||
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
|
||||
exec setsid uwsm-app -- 1password --force-device-scale-factor=1
|
||||
exec setsid uwsm-app -- 1password
|
||||
else
|
||||
exec omarchy-launch-floating-terminal-with-presentation omarchy-install-service-1password
|
||||
fi
|
||||
@@ -3,7 +3,10 @@
|
||||
# omarchy:summary=Launch the default browser as determined by xdg-settings.
|
||||
# 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)
|
||||
|
||||
if $browser_exec --help 2>/dev/null | grep -q MOZ_LOG; then
|
||||
@@ -14,11 +17,9 @@ else
|
||||
private_flag="--incognito"
|
||||
fi
|
||||
|
||||
if ! omarchy-cmd-browser-handoff "$browser_exec" "${@/--private/$private_flag}"; then
|
||||
systemd-run --user --quiet --collect --unit="omarchy-browser-$(date +%s%N)" \
|
||||
--property=StandardOutput=null --property=StandardError=null \
|
||||
uwsm-app -- "$browser_exec" "${@/--private/$private_flag}"
|
||||
fi
|
||||
systemd-run --user --quiet --collect --unit="omarchy-browser-$(date +%s%N)" \
|
||||
--property=StandardOutput=null --property=StandardError=null \
|
||||
uwsm-app -- "$browser_exec" "${@/--private/$private_flag}"
|
||||
|
||||
url=""
|
||||
for argument in "$@"; do
|
||||
|
||||
Loaded 100 of 1031 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user