Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4505badecb |
No files matched your search
@@ -0,0 +1,2 @@
|
||||
# Merges to protected branches need sign-off from an org owner.
|
||||
* @dhh @ryanrhughes
|
||||
@@ -1,7 +1,7 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Suggestion
|
||||
url: https://github.com/omacom/omarchy/discussions/categories/suggestions
|
||||
url: https://github.com/basecamp/omarchy/discussions/categories/suggestions
|
||||
about: Suggest a new feature, change to existing feature, or other ideas in Discussions.
|
||||
- name: Support
|
||||
url: https://omarchy.org/discord
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# Security at Omarchy
|
||||
|
||||
## Report a vulnerability
|
||||
|
||||
If you believe you’ve found a security vulnerability in Omarchy, please tell the [Omarchy Security Team](https://omarchy.org/teams/#security) privately so we have an opportunity to investigate and fix it before it is made public.
|
||||
|
||||
[security@omarchy.org](mailto:security@omarchy.org?subject=Security%20report)
|
||||
|
||||
Please don’t report potential vulnerabilities publicly in GitHub Issues, Discord, or social media before they’ve been resolved.
|
||||
|
||||
## What is a vulnerability?
|
||||
|
||||
We consider a bug a security vulnerability when it can be exploited to cross a meaningful security boundary: an untrusted or lower-privileged party gains access, permissions, or control they didn’t already have.
|
||||
|
||||
Code that could be more robust but does not cross a security boundary is an improvement rather than a security vulnerability. We may still merge a proposed fix and credit the reporter in our release notes.
|
||||
|
||||
Eligibility for our [security credits](https://omarchy.org/security/credits/) page depends on whether a report identifies a confirmed security vulnerability, not on its severity.
|
||||
|
||||
## What to include
|
||||
|
||||
Give us enough information to understand and reproduce the issue:
|
||||
|
||||
- The affected component and Omarchy version.
|
||||
- An explanation of what an attacker can do before and after exploitation.
|
||||
- Steps to reproduce the issue and any proof of concept.
|
||||
- Your preferred contact details for follow-up.
|
||||
|
||||
## Responsible disclosure
|
||||
|
||||
Please act in good faith while investigating and reporting vulnerabilities:
|
||||
|
||||
- Only test systems and accounts you own or have explicit permission to test.
|
||||
- Avoid privacy violations, disruption, data destruction, and service degradation.
|
||||
- Don’t exploit a vulnerability beyond what is needed to demonstrate it.
|
||||
- Give us a reasonable opportunity to investigate and address the issue before publishing details.
|
||||
|
||||
We’ll review your report and keep you informed as we’re able while we work toward a resolution.
|
||||
|
||||
## Credits
|
||||
|
||||
Researchers who privately report a confirmed security vulnerability and give us the chance to ship a fix are thanked on the [security credits](https://omarchy.org/security/credits/) page. Accepted improvements that don’t cross a security boundary may still be credited in our release notes.
|
||||
|
||||
Credits link to each reporter’s X profile and show their avatar. For duplicate reports, only the first reporter is eligible for credit.
|
||||
|
||||
## Regular bugs and support
|
||||
|
||||
For anything that isn’t a security vulnerability, please use the [Omarchy issue tracker](https://github.com/omacom/omarchy/issues).
|
||||
@@ -2,5 +2,3 @@
|
||||
# Python bytecode (orchestrator)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
.claude/
|
||||
@@ -28,7 +28,7 @@ Three documentation trees, split by genre and audience:
|
||||
- Prefer `(( ))` over numeric operators inside `[[ ]]` (e.g., `(( count < 50 ))`, not `[[ $count -lt 50 ]]`)
|
||||
- Prefer a full `if`/`else` conditional for simple two-path control flow; don't rely on `exec` or `exit` in one branch to make following statements unreachable
|
||||
- For strings/paths with spaces, quote them instead of escaping spaces with `\ ` (e.g., `"$APP_DIR/Disk Usage.desktop"`, not `$APP_DIR/Disk\ Usage.desktop`)
|
||||
- Shebangs must use `#!/bin/bash` consistently (never `#!/usr/bin/env bash`). A security-sensitive entrypoint may use the exact `#!/bin/bash -p` form only when it must suppress `BASH_ENV` and exported-function startup injection before its first command; that exception must be explained at the boundary and covered by a regression that rejects an ordinary Bash launch with a decoy `-p` argument.
|
||||
- Shebangs must use `#!/bin/bash` consistently (never `#!/usr/bin/env bash`)
|
||||
- Scripts under `install/` and `migrations/` may be sourced and intentionally omit shebangs
|
||||
|
||||
# Command Naming
|
||||
@@ -96,11 +96,6 @@ Exceptions are allowed for migration and package-helper scripts where the helper
|
||||
- Do not add `aliases` to new menu entries. Aliases are reserved for
|
||||
established alternate names users already type, kept for compatibility.
|
||||
|
||||
# Region
|
||||
|
||||
- `/etc/omarchy/region` holds the region the installer derived from the timezone (`cn`); no file means Global. The profile lives in `default/regions/cn/`, its contract in `docs/regions.md`.
|
||||
- `install/config/region.sh` links the `cn` developer mirrors into `/etc` and `/.cargo` and refuses to replace a regular file it did not create.
|
||||
|
||||
# Config Structure
|
||||
|
||||
- `config/` - default configs copied to `~/.config/`
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# Omarchy
|
||||
|
||||
Omarchy is a beautiful, fun & agentic Linux distribution by DHH.
|
||||
Omarchy is a beautiful, modern & opinionated Linux distribution by DHH.
|
||||
|
||||
Read more at [omarchy.org](https://omarchy.org).
|
||||
|
||||
## The Omarchy Manual
|
||||
|
||||
The manual lives in [`manual/`](manual/), which is its authoritative source.
|
||||
The manual lives in [`manual/`](manual/), which is its authoritative source. It's
|
||||
mirrored to [learn.omacom.io](https://learn.omacom.io/2/the-omarchy-manual), where
|
||||
its screenshots are also hosted.
|
||||
|
||||
- [Welcome to Omarchy!](manual/01-welcome-to-omarchy.md)
|
||||
|
||||
|
||||
@@ -70,6 +70,10 @@ magick -background white -fill black -font default/fonts/omarchy/omarchy.ttf \
|
||||
-pointsize 110 label:@/tmp/row.txt /tmp/font-row.png
|
||||
```
|
||||
|
||||
Then confirm it in the running menu per [`visual-verification.md`](visual-verification.md). Avoid leaving two fonts with the `omarchy` family registered: Qt can use an old copy in `~/.local/share/fonts` even when `fc-match omarchy` reports the packaged font. For a preview, either replace the packaged file in the disposable VM or temporarily exclude it with a `<rejectfont>` rule in `~/.config/fontconfig/conf.d/` before loading the candidate.
|
||||
|
||||
Refresh the font cache and restart the shell afterwards — Qt reads the font database at startup, so `omarchy menu refresh` alone will not pick up a changed font. Remove temporary fonts and rules after verification.
|
||||
Then confirm it in the running menu per
|
||||
[`visual-verification.md`](visual-verification.md). Fontconfig prefers the
|
||||
packaged font over a copy in `~/.local/share/fonts` for the same family, so a
|
||||
preview needs either the real file replaced or a `<rejectfont>` rule in
|
||||
`~/.config/fontconfig/conf.d/` pointing fontconfig away from the packaged one.
|
||||
Restart the shell afterwards — Qt reads the font database at startup, so
|
||||
`omarchy menu refresh` alone will not pick up a changed font.
|
||||
@@ -13,7 +13,6 @@ commands and reusable setup leaves:
|
||||
- use `$OMARCHY_INSTALL` and `$OMARCHY_PATH` instead of hard-coded Omarchy paths.
|
||||
- keep root-scoped hardware setup under `install/hardware/` and orchestrate it through `install/hardware/all.sh`.
|
||||
- keep every per-user setup leaf under `install/user/` (including `install/user/hardware/` and `install/user/first-run/`) so it is clear what must run for each user.
|
||||
- The base install supplies matching kernel headers before hardware setup. DKMS installers should install their driver packages and assume the headers exist.
|
||||
- prefer helper commands for package and command checks where available.
|
||||
|
||||
Raw `command -v`, `pacman`, and `pacman-key` are acceptable in package-helper
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
File renamed without changes.
+1
-27
@@ -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"
|
||||
@@ -65,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"
|
||||
@@ -371,20 +370,6 @@ command_requires_args() {
|
||||
[[ -n $required ]]
|
||||
}
|
||||
|
||||
# A command whose documented arguments are all flags can't take a word, so a
|
||||
# word after it names something else: `omarchy agent account` is the account
|
||||
# commands, not an argument to omarchy-agent.
|
||||
command_takes_only_flags() {
|
||||
local key="$1"
|
||||
local args="${COMMAND_ARGS[$key]}"
|
||||
local token=""
|
||||
|
||||
[[ -n $args ]] || return 1
|
||||
for token in $args; do
|
||||
[[ $token == -* || $token == "[-"* ]] || return 1
|
||||
done
|
||||
}
|
||||
|
||||
load_group_commands() {
|
||||
local group="$1"
|
||||
local file=""
|
||||
@@ -970,17 +955,6 @@ dispatch_fast_or_help() {
|
||||
remaining=("${args[@]:DIRECT_RESOLVED_COUNT}")
|
||||
binary_path="$OMARCHY_BIN_DIR/$DIRECT_RESOLVED_BINARY"
|
||||
|
||||
if (( ${#remaining[@]} == 1 || ${#remaining[@]} == 2 )) && [[ ${remaining[0]} != -* ]] &&
|
||||
{ (( ${#remaining[@]} == 1 )) || remaining_has_help_flag "${remaining[1]}"; } &&
|
||||
group_has_child_commands "${DIRECT_RESOLVED_BINARY#omarchy-}-${remaining[0]}"; then
|
||||
load_command_by_binary "$DIRECT_RESOLVED_BINARY"
|
||||
key="${BINARY_TO_KEY[$DIRECT_RESOLVED_BINARY]}"
|
||||
if [[ -n $key ]] && command_takes_only_flags "$key"; then
|
||||
load_commands
|
||||
show_prefix_help "$DIRECT_RESOLVED_ROUTE ${remaining[0]}" && return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if remaining_has_help_flag "${remaining[@]}"; then
|
||||
if ! load_command_by_binary "$DIRECT_RESOLVED_BINARY"; then
|
||||
echo "Binary is missing or not executable: $DIRECT_RESOLVED_BINARY" >&2
|
||||
|
||||
+14
-52
@@ -52,7 +52,8 @@ if omarchy-cmd-missing "$agent"; then
|
||||
fi
|
||||
|
||||
# Agents launched from the keybinding or menu run unattended, so each one starts
|
||||
# with its own spelling of "don't stop to ask". Pi and Ori have none to skip.
|
||||
# with its own spelling of "don't stop to ask". Pi, Ori and Dim have none to
|
||||
# skip.
|
||||
case "$agent" in
|
||||
opencode)
|
||||
command=(opencode --auto)
|
||||
@@ -78,43 +79,27 @@ claude)
|
||||
command=(claude --permission-mode auto)
|
||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
||||
;;
|
||||
dim)
|
||||
# `dim exec` is the one-shot prompt form; interactive dim is the bare TUI.
|
||||
if [[ -n ${prompt:-} ]]; then
|
||||
command=(dim exec "$prompt")
|
||||
else
|
||||
command=(dim)
|
||||
fi
|
||||
;;
|
||||
grok)
|
||||
command=(grok --permission-mode bypassPermissions)
|
||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
||||
;;
|
||||
openclaw)
|
||||
# The launcher owns onboarding and the gateway dance: OpenClaw's terminal UI
|
||||
# must attach to the running gateway (the embedded `openclaw chat` refuses to
|
||||
# start while the gateway owns the state directory). It has no permission
|
||||
# prompts to skip, and --message seeds the session while keeping it
|
||||
# interactive.
|
||||
command=(omarchy-launch-openclaw --tui)
|
||||
[[ -n ${prompt:-} ]] && command+=(--message "$prompt")
|
||||
kimi)
|
||||
# --prompt is kimi's one-shot form, like `crush run`; --auto spares its questions.
|
||||
command=(kimi --auto)
|
||||
[[ -n ${prompt:-} ]] && command+=(--prompt "$prompt")
|
||||
;;
|
||||
codex)
|
||||
command=(codex --approve-for-me)
|
||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
||||
;;
|
||||
cursor-agent)
|
||||
# --yolo covers commands only; the workspace trust dialog has its own flag.
|
||||
# A one-word prompt naming a subcommand (update, login, help) still runs that
|
||||
# subcommand after a bare --, so the agent subcommand is named outright, and
|
||||
# -- after it keeps a prompt starting with a dash from being read as an option.
|
||||
command=(cursor-agent --yolo --trust)
|
||||
[[ -n ${prompt:-} ]] && command+=(agent -- "$prompt")
|
||||
;;
|
||||
hermes)
|
||||
if [[ -n ${prompt:-} ]]; then
|
||||
command=(env -u HERMES_SESSION_SOURCE hermes chat --yolo --tui "--query=$prompt")
|
||||
else
|
||||
command=(hermes --yolo)
|
||||
fi
|
||||
;;
|
||||
muse)
|
||||
# --approval-mode never skips the tool prompts but keeps Muse's own sandbox.
|
||||
command=(muse --approval-mode never)
|
||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
||||
;;
|
||||
omp)
|
||||
command=(omp --auto-approve)
|
||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
||||
@@ -136,29 +121,6 @@ pi)
|
||||
;;
|
||||
esac
|
||||
|
||||
# A new session uses the current selection rather than its parent's account
|
||||
# pin. Explicit provider homes still override the selection. Carry the new pin
|
||||
# in the command, since terminals launched through uwsm don't inherit our env.
|
||||
case "$agent" in
|
||||
claude) home_env="CLAUDE_CONFIG_DIR" ;;
|
||||
codex) home_env="CODEX_HOME" ;;
|
||||
grok) home_env="GROK_HOME" ;;
|
||||
*) home_env="" ;;
|
||||
esac
|
||||
if [[ -n $home_env ]]; then
|
||||
account_pin="OMARCHY_AGENT_${agent^^}_HOME"
|
||||
if [[ -v $account_pin && ${!home_env:-} == "${!account_pin}" ]]; then
|
||||
unset "$home_env"
|
||||
fi
|
||||
unset "$account_pin"
|
||||
if [[ -z ${!home_env:-} ]]; then
|
||||
home=$(omarchy-agent-account-home "$agent")
|
||||
account_env=(env -u "$home_env" "$account_pin=$home")
|
||||
[[ -n $home ]] && account_env+=("$home_env=$home")
|
||||
command=("${account_env[@]}" "${command[@]}")
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $inline == "true" ]]; then
|
||||
exec "${command[@]}"
|
||||
else
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Sign in to a Claude, Codex, or Grok subscription
|
||||
# omarchy:args=[--check] [--events] [--reauth <account>] [claude|codex|grok] [label]
|
||||
# omarchy:examples=omarchy agent account add claude | omarchy agent account add Work | omarchy agent account add codex Side
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# --check prints, per provider, whether adding one now would be its first
|
||||
# sign-in, an additional account, or not possible yet. --events is for the
|
||||
# agents panel: progress arrives as `@@omarchy <event> <detail>` lines mixed
|
||||
# into the CLI's own output (which carries codes the panel shows), nothing
|
||||
# waits on a terminal, and the result also lands as a notification in case
|
||||
# the panel has closed.
|
||||
# --reauth <account> signs an account that's already here in again, in its
|
||||
# own home, for when its sign-in has lapsed.
|
||||
check=false
|
||||
events=false
|
||||
reauth=""
|
||||
while [[ ${1:-} == --* ]]; do
|
||||
case "$1" in
|
||||
--check) check=true ;;
|
||||
--events) events=true ;;
|
||||
--reauth)
|
||||
reauth="${2:-}"
|
||||
shift
|
||||
;;
|
||||
*) break ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# Whether a provider's own home (~/.claude, ~/.codex, ~/.grok) is signed in.
|
||||
primary_signed_in() {
|
||||
omarchy-agent-account-state list "$1" | jq -e '.[0].accounts | any(.primary and .signedIn)' >/dev/null
|
||||
}
|
||||
|
||||
if [[ $check == "true" ]]; then
|
||||
for p in claude codex grok; do
|
||||
if primary_signed_in "$p"; then
|
||||
echo "$p additional"
|
||||
else
|
||||
echo "$p first"
|
||||
fi
|
||||
done
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# The provider can be left out when it's your default agent:
|
||||
# `omarchy agent account add Work`.
|
||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
|
||||
default=$(omarchy-default-agent)
|
||||
[[ $default == "claude" || $default == "codex" || $default == "grok" ]] && set -- "$default" "$@"
|
||||
fi
|
||||
|
||||
case "${1:-}" in
|
||||
claude) provider="claude"; name="Claude"; site="claude.ai"; package="claude"; home_env="CLAUDE_CONFIG_DIR" ;;
|
||||
codex) provider="codex"; name="Codex"; site="chatgpt.com"; package="codex"; home_env="CODEX_HOME" ;;
|
||||
grok) provider="grok"; name="Grok"; site="x.ai"; package="grok"; home_env="GROK_HOME" ;;
|
||||
*)
|
||||
echo "Usage: omarchy-agent-account-add [--check] [--events] [--reauth <account>] [claude|codex|grok] [label]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
label="${2:-}"
|
||||
|
||||
say() {
|
||||
if [[ $events == "true" ]]; then
|
||||
printf '@@omarchy status %s\n' "$*"
|
||||
else
|
||||
echo "$*"
|
||||
fi
|
||||
}
|
||||
|
||||
finish() {
|
||||
if [[ $events == "true" ]]; then
|
||||
printf '@@omarchy done %s\n' "$1"
|
||||
omarchy-notification-send -g "$1"
|
||||
else
|
||||
echo
|
||||
echo "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
fail() {
|
||||
if [[ $events == "true" ]]; then
|
||||
printf '@@omarchy error %s\n' "$1"
|
||||
omarchy-notification-send -g -u critical "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
|
||||
+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
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Restore channel and regional pacman defaults in an installed target
|
||||
# omarchy:args=<stable|rc|edge> [target-root] | --render <stable|rc|edge> <output-dir> [target-root]
|
||||
# omarchy:requires-sudo=true
|
||||
# omarchy:hidden=true
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
echo "Usage: omarchy-apply-pacman <stable|rc|edge> [target-root]" >&2
|
||||
echo " omarchy-apply-pacman --render <stable|rc|edge> <output-dir> [target-root]" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --render only builds the two files, so an unprivileged caller can hand root
|
||||
# nothing but plain copies of the finished result.
|
||||
render_dir=""
|
||||
if [[ ${1:-} == "--render" ]]; then
|
||||
(( $# == 3 || $# == 4 )) || usage
|
||||
channel="$2"
|
||||
render_dir="$3"
|
||||
[[ -d $render_dir ]] || { echo "Render directory not found: $render_dir" >&2; exit 1; }
|
||||
etc="${4:-/}/etc"
|
||||
else
|
||||
(( $# >= 1 && $# <= 2 )) || usage
|
||||
channel="$1"
|
||||
etc="${2:-/}/etc"
|
||||
fi
|
||||
|
||||
case "$channel" in
|
||||
stable|rc|edge) ;;
|
||||
*) echo "Invalid pacman channel: $channel" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
# The ISO writes this plain-text marker before target finalization. Missing
|
||||
# markers keep existing installations global; never source it as shell code.
|
||||
region=global
|
||||
if [[ -e $etc/omarchy/region ]]; then
|
||||
region=$(<"$etc/omarchy/region")
|
||||
fi
|
||||
case "$region" in
|
||||
global|cn) ;;
|
||||
*) echo "Unsupported region: $region (expected global or cn)" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
# Build both files before replacing either. Explicit refreshes retain backups;
|
||||
# ordinary updates do not call this command or enforce regional defaults.
|
||||
if [[ -n $render_dir ]]; then
|
||||
staging="$render_dir"
|
||||
else
|
||||
staging=$(mktemp -d)
|
||||
trap 'rm -rf "$staging"' EXIT
|
||||
fi
|
||||
cp "$OMARCHY_PATH/default/pacman/pacman-$channel.conf" "$staging/pacman.conf"
|
||||
if [[ $region == "global" ]]; then
|
||||
cp "$OMARCHY_PATH/default/pacman/mirrorlist-$channel" "$staging/mirrorlist"
|
||||
else
|
||||
profile="$OMARCHY_PATH/default/regions/$region/pacman"
|
||||
printf '\n' >> "$staging/pacman.conf"
|
||||
cat "$profile/pacman.conf.append" >> "$staging/pacman.conf"
|
||||
# pacman tries servers in order, so the regional mirror leads and the
|
||||
# channel's own mirror stays as the fallback.
|
||||
cat "$profile/mirrorlist-$channel.prepend" "$OMARCHY_PATH/default/pacman/mirrorlist-$channel" > "$staging/mirrorlist"
|
||||
fi
|
||||
|
||||
if [[ -z $render_dir ]]; then
|
||||
mkdir -p "$etc/pacman.d"
|
||||
for file in pacman.conf pacman.d/mirrorlist; do
|
||||
if [[ -e $etc/$file ]]; then
|
||||
cp -f "$etc/$file" "$etc/$file.bak"
|
||||
fi
|
||||
done
|
||||
# Copying over an existing file keeps its owner and mode, as refresh always has.
|
||||
cp -f "$staging/pacman.conf" "$etc/pacman.conf"
|
||||
cp -f "$staging/mirrorlist" "$etc/pacman.d/mirrorlist"
|
||||
fi
|
||||
@@ -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"
|
||||
@@ -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 \
|
||||
|
||||
@@ -15,9 +15,8 @@
|
||||
# start.
|
||||
#
|
||||
# Env: OMARCHY_SCREENRECORD_DEBUG=true appends gpu-screen-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.
|
||||
# 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}}"
|
||||
@@ -35,18 +34,9 @@ WEBCAM_SIZE="medium"
|
||||
RESOLUTION=""
|
||||
FULLSCREEN="false"
|
||||
STOP_RECORDING="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"
|
||||
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
|
||||
@@ -154,11 +144,6 @@ select_capture_target() {
|
||||
}
|
||||
|
||||
start_screenrecording() {
|
||||
mkdir -p "$RUNTIME_DIR" || return 1
|
||||
# XDG_STATE_HOME may be outside a private home; protect existing fallbacks too.
|
||||
if [[ -z ${XDG_RUNTIME_DIR:-} ]]; then
|
||||
chmod 700 "$RUNTIME_DIR" || return 1
|
||||
fi
|
||||
local capture_args=()
|
||||
local target
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+7
-80
@@ -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; }
|
||||
@@ -47,63 +32,13 @@ validate_dev_checkout() {
|
||||
fi
|
||||
}
|
||||
|
||||
# The dev channel refreshes pacman with the checkout's own tools. A checkout
|
||||
# without this machine's region profile would silently drop its repositories.
|
||||
validate_dev_region() {
|
||||
local checkout="$1" region_file="${OMARCHY_REGION_FILE:-/etc/omarchy/region}" region=global
|
||||
|
||||
[[ -f $region_file ]] && region=$(<"$region_file")
|
||||
[[ $region == "global" ]] && return 0
|
||||
|
||||
# The refresh after linking needs a runnable helper and every fragment, not
|
||||
# just a directory with the region's name: dev refreshes edge, and leaving
|
||||
# dev later refreshes stable or rc with this checkout's tools.
|
||||
local profile="$checkout/default/regions/$region/pacman" fragment supported=true
|
||||
[[ -x $checkout/bin/omarchy-apply-pacman ]] || supported=false
|
||||
for fragment in pacman.conf.append mirrorlist-stable.prepend mirrorlist-rc.prepend mirrorlist-edge.prepend; do
|
||||
[[ -r $profile/$fragment ]] || supported=false
|
||||
done
|
||||
if ! $supported; then
|
||||
fail "$checkout does not support the '$region' region, so switching would drop its package repositories. Check out a branch with default/regions/$region, or remove $region_file to use global defaults."
|
||||
fi
|
||||
}
|
||||
|
||||
link_dev_checkout() {
|
||||
local checkout="$1" required
|
||||
[[ -d $checkout/.git ]] || git clone https://github.com/omacom/omarchy.git "$checkout"
|
||||
validate_dev_region "$checkout"
|
||||
|
||||
# Check the destination before changing /etc/omarchy.conf or sudo's path.
|
||||
# An existing checkout is not pulled automatically and may predate this policy.
|
||||
for required in bin/omarchy-security-functions bin/omarchy-update bin/omarchy-refresh-pacman default/omarchy/sudo-no-update/sudo; do
|
||||
if [[ ! -f $checkout/$required || ! -r $checkout/$required ||
|
||||
( $required != "bin/omarchy-security-functions" && ! -x $checkout/$required ) ]]; then
|
||||
fail "Update the checkout before switching to dev; missing required update support in $required."
|
||||
fi
|
||||
done
|
||||
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=""
|
||||
@@ -137,10 +72,6 @@ case "$channel" in
|
||||
;;
|
||||
esac
|
||||
|
||||
# A failure past this point leaves the channel switch half-applied, so say how
|
||||
# to pick it back up rather than dying silently under set -e.
|
||||
trap 'echo -e "\nThe channel switch did not complete. Review the error above, then rerun: omarchy-channel-set '"$channel"'" >&2' ERR
|
||||
|
||||
if [[ -z $dev_checkout && $OMARCHY_PATH != "/usr/share/omarchy" ]]; then
|
||||
leaving_dev=1
|
||||
fi
|
||||
@@ -148,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
|
||||
@@ -166,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|dim|kimi]
|
||||
# omarchy:examples=omarchy default agent | omarchy default agent codex | omarchy default agent claude
|
||||
|
||||
installing=false
|
||||
@@ -31,57 +31,25 @@ ori | openrouter) agent="ori"; name="Ori"; agent_package="github:OpenRouterLabs/
|
||||
claude | claude-code) agent="claude"; name="Claude Code" ;;
|
||||
codex) agent="codex"; name="Codex" ;;
|
||||
crush) agent="crush"; name="Crush" ;;
|
||||
grok) agent="grok"; name="Grok" ;;
|
||||
openclaw) agent="openclaw"; name="OpenClaw"; agent_installer="omarchy-install-openclaw-cli" ;;
|
||||
dim | dimcode | dim-agent) agent="dim"; name="Dim Agent"; agent_package="npm:dimcode" ;;
|
||||
grok) agent="grok"; name="Grok"; agent_package="npm:@xai-official/grok" ;;
|
||||
kimi | kimi-code) agent="kimi"; name="Kimi Code"; agent_package="npm:@moonshot-ai/kimi-code" ;;
|
||||
agy | antigravity | antigravity-cli | gemini | gemini-cli) agent="agy"; name="Antigravity"; agent_package="antigravity-cli" ;;
|
||||
hermes) agent="hermes"; name="Hermes"; agent_installer="omarchy-install-hermes-cli" ;;
|
||||
copilot | github-copilot) agent="copilot"; name="GitHub Copilot" ;;
|
||||
muse | muse-code | musecode)
|
||||
agent="muse"; name="Muse Code"
|
||||
# Meta's launcher verifies and updates the native binary for this platform.
|
||||
agent_package="http:muse[url=https://api.meta.ai/muse-launcher.sh,bin=muse,version_list_url=https://api.meta.ai/muse-code/channels/muse-stable,version_json_path=.version]"
|
||||
;;
|
||||
cursor | cursor-agent) agent="cursor-agent"; name="Cursor CLI" ;;
|
||||
*)
|
||||
echo "Usage: omarchy-default-agent <pi|omp|opencode|ori|claude|codex|grok|openclaw|agy|hermes|copilot|crush|cursor-agent|muse>"
|
||||
echo "Usage: omarchy-default-agent <pi|omp|opencode|ori|claude|codex|grok|agy|copilot|crush|dim|kimi>"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
agent_package=${agent_package:-$agent}
|
||||
|
||||
# Hermes and OpenClaw are not mise tools: each is a self-updating runtime in
|
||||
# the user's home, Hermes the desktop app's and OpenClaw the one its Control
|
||||
# UI updates. Each has its own installer with the same --check/--now contract
|
||||
# mise-backed agents get from mise; see omarchy-install-hermes-cli and
|
||||
# omarchy-install-openclaw-cli.
|
||||
if [[ -n ${agent_installer:-} ]]; then
|
||||
# Not omarchy-cmd-present: a command on PATH says nothing about whether the
|
||||
# install behind it finished or runs the sessions omarchy-agent starts.
|
||||
# Treating it as installed would skip the floating terminal and run a
|
||||
# minutes-long install inside the menu action instead.
|
||||
agent_present() { "$agent_installer" --check; }
|
||||
agent_install() { "$agent_installer" --now; }
|
||||
else
|
||||
# Anything at the wrapper's path other than the wrapper is the user's own
|
||||
# install, such as the symlink Cursor's installer leaves. A mise copy would
|
||||
# only shadow it, since the mise shims precede ~/.local/bin on PATH.
|
||||
user_install() {
|
||||
[[ -x $HOME/.local/bin/$agent ]] &&
|
||||
{ [[ -L $HOME/.local/bin/$agent ]] || ! grep -q '^mise use -g' "$HOME/.local/bin/$agent"; }
|
||||
}
|
||||
agent_present() { user_install || mise where "$agent_package" &>/dev/null; }
|
||||
agent_install() { user_install || mise use -g "$agent_package"; }
|
||||
fi
|
||||
|
||||
if [[ $installing == "false" ]] && ! agent_present; then
|
||||
if [[ $installing == "false" ]] && ! mise where "$agent_package" &>/dev/null; then
|
||||
exec omarchy-launch-floating-terminal-with-presentation omarchy-default-agent --install "$agent"
|
||||
fi
|
||||
|
||||
if ! agent_install; then
|
||||
if [[ $installing == "true" && -n ${agent_installer:-} ]]; then
|
||||
echo "Could not install $name" >&2
|
||||
elif [[ $installing == "true" ]]; then
|
||||
if ! mise use -g "$agent_package"; then
|
||||
if [[ $installing == "true" ]]; then
|
||||
echo "Could not install $name with mise" >&2
|
||||
else
|
||||
echo "Could not set $name as the default coding agent" >&2
|
||||
@@ -89,10 +57,6 @@ if ! agent_install; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ $agent == "claude" ]]; then
|
||||
omarchy-install-chromium-claude 2>/dev/null || true
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$agent_file")"
|
||||
printf '%s\n' "$agent" >"$agent_file"
|
||||
|
||||
|
||||
@@ -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>"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -30,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
|
||||
@@ -112,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 an Elgato Cam Link 4K is plugged in.
|
||||
|
||||
grep -qsx "Cam Link 4K" /sys/bus/usb/devices/*/product
|
||||
@@ -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,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Returns true when running in a virtual machine.
|
||||
|
||||
systemd-detect-virt --vm --quiet
|
||||
@@ -2,18 +2,6 @@
|
||||
|
||||
# omarchy:summary=Toggles transparency for the currently focused window.
|
||||
|
||||
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,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."
|
||||
@@ -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)
|
||||
|
||||
@@ -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,15 +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-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
|
||||
|
||||
@@ -18,20 +18,17 @@ else
|
||||
editor="nvim"
|
||||
fi
|
||||
|
||||
# sublime-text-4 ships `subl` on PATH, not `sublime_text`.
|
||||
[[ $editor == sublime_text ]] && editor=subl
|
||||
|
||||
omarchy-cmd-present "$editor" || editor="nvim"
|
||||
|
||||
case "${editor##*/}" in
|
||||
nvim | vim | nano | micro | hx | helix | fresh)
|
||||
if [[ $inline == "true" ]]; then
|
||||
exec "$editor" -- "$@"
|
||||
exec "$editor" "$@"
|
||||
else
|
||||
exec omarchy-launch-tui "$editor" -- "$@"
|
||||
exec omarchy-launch-tui "$editor" "$@"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
exec setsid uwsm-app -- "$editor" -- "$@"
|
||||
exec setsid uwsm-app -- "$editor" "$@"
|
||||
;;
|
||||
esac
|
||||
@@ -8,7 +8,6 @@
|
||||
source omarchy-restart-gum
|
||||
|
||||
cmd="$*"
|
||||
# 130 is the user bailing with Ctrl-C; anything else gets the Done/Failed prompt.
|
||||
presentation_script="omarchy-show-logo; $cmd; code=\$?; if (( code != 130 )); then omarchy-show-done \$code; fi"
|
||||
presentation_script="omarchy-show-logo; $cmd; if (( \$? != 130 )); then omarchy-show-done; fi"
|
||||
|
||||
exec setsid uwsm-app -- xdg-terminal-exec --app-id=org.omarchy.terminal --title=Omarchy -e bash -c "$presentation_script"
|
||||
@@ -1,91 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Open the OpenClaw Control UI (or its terminal UI with --tui), onboarding or starting the gateway first when needed.
|
||||
# omarchy:args=[--tui [--message <text>]]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
tui=false
|
||||
message=()
|
||||
if [[ ${1:-} == "--tui" ]]; then
|
||||
tui=true
|
||||
shift
|
||||
if [[ ${1:-} == "--message" ]]; then
|
||||
message=(--message "${2:?--message needs a value}")
|
||||
shift 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# Onboarding is OpenClaw's own interactive wizard, run through
|
||||
# omarchy-openclaw-onboard so it stays in the terminal, installs the gateway
|
||||
# as a user service, and returns (see that script for why bare `openclaw
|
||||
# onboard` does none of those as of 2026.9.1). The config check gates what
|
||||
# follows because the wizard's "Skip for now" also exits 0: only inference
|
||||
# that passed writes the config, and skipping must not loop back into the
|
||||
# wizard.
|
||||
if [[ ! -f $HOME/.openclaw/openclaw.json ]]; then
|
||||
if [[ $tui == "true" ]]; then
|
||||
# Already in a terminal, so the wizard runs right here.
|
||||
omarchy-openclaw-onboard
|
||||
[[ -f $HOME/.openclaw/openclaw.json ]] || exit 1
|
||||
else
|
||||
# The wizard needs a real terminal, and chaining the relaunch means
|
||||
# finishing it lands the user in the app. Single quotes so $HOME expands
|
||||
# in the spawned terminal.
|
||||
# shellcheck disable=SC2016
|
||||
exec omarchy-launch-floating-terminal-with-presentation \
|
||||
'omarchy-openclaw-onboard && [[ -f $HOME/.openclaw/openclaw.json ]] && omarchy-launch-openclaw'
|
||||
fi
|
||||
fi
|
||||
|
||||
dashboard_url() {
|
||||
# browserUrl carries a single-use browser handoff; url is the shared-auth
|
||||
# fallback for gateways predating the handoff flow. The timeout keeps a
|
||||
# wedged CLI from hanging an app-grid launch that has no terminal to ^C.
|
||||
timeout 10 openclaw dashboard --json 2>/dev/null | jq -re '.browserUrl // .url // empty'
|
||||
}
|
||||
|
||||
# --json never starts the gateway, so an empty answer means it is not running.
|
||||
# Recovery goes through the gateway's own service commands. `dashboard --yes`
|
||||
# used to be the start/install-without-prompting path, but as of 2026.9.1 it
|
||||
# defers to "the owning supervisor" for both a missing and a stopped unit,
|
||||
# and once the gateway is up it copies a one-time pairing URL into the
|
||||
# clipboard, which nothing here needs. Enablement, not the unit file, decides:
|
||||
# a unit that was written but never enabled (an install that died halfway)
|
||||
# would otherwise be "started" once and stay off at every following login,
|
||||
# where --force rewrites and enables it.
|
||||
if ! url=$(dashboard_url); then
|
||||
if systemctl --user is-enabled --quiet openclaw-gateway.service 2>/dev/null; then
|
||||
timeout 60 openclaw gateway start >&2 || true
|
||||
else
|
||||
timeout 120 openclaw gateway install --force >&2 || true
|
||||
fi
|
||||
|
||||
# A first-ever service install (unit write, daemon-reload, first boot)
|
||||
# takes materially longer than starting an installed unit, so the budget
|
||||
# is sized for the slow case.
|
||||
for _ in {1..30}; do
|
||||
url=$(dashboard_url) && break
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -z ${url:-} ]]; then
|
||||
echo "OpenClaw's gateway did not come up. Check it with: openclaw gateway status" >&2
|
||||
echo "If onboarding never finished, rerun it with: omarchy-openclaw-onboard" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ $tui == "true" ]]; then
|
||||
# Attach to the gateway rather than `openclaw chat`: chat is the embedded
|
||||
# local runtime, which refuses to start while the gateway owns ~/.openclaw's
|
||||
# state directory -- and on any machine set up through Install > AI, the
|
||||
# gateway service is running whenever the desktop session is.
|
||||
exec openclaw tui "${message[@]}"
|
||||
fi
|
||||
|
||||
# The handoff URL rides in the browser's argv, which uwsm's app daemon echoes
|
||||
# into the user journal. Accepted: the token is single-use with a ten-minute
|
||||
# expiry against a loopback-only gateway, and journal access already implies
|
||||
# access to ~/.openclaw itself.
|
||||
exec omarchy-launch-webapp "$url"
|
||||
@@ -29,8 +29,7 @@ hypr_exec() {
|
||||
local command
|
||||
printf -v command '%q ' "$@"
|
||||
|
||||
hyprctl dispatch "hl.dsp.exec_cmd([[[workspace $workspace] $command]])" >/dev/null 2>&1 ||
|
||||
hyprctl dispatch exec -- "[workspace $workspace]" bash -lc "$command" >/dev/null
|
||||
hyprctl dispatch "hl.dsp.exec_cmd([[$command]])" >/dev/null 2>&1 || hyprctl dispatch exec -- bash -lc "$command" >/dev/null
|
||||
}
|
||||
|
||||
SOCKET="$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock"
|
||||
@@ -50,10 +49,8 @@ wait_for_screensaver_window() {
|
||||
done
|
||||
}
|
||||
|
||||
while read -r m special; do
|
||||
for m in $(hyprctl monitors -j | jq -r '.[] | .name'); do
|
||||
hypr_focus_monitor "$m"
|
||||
# Share a special workspace that is already showing, like the scratchpad, so it is still open afterwards.
|
||||
workspace=${special:-special:screensaver-$m}
|
||||
|
||||
case $terminal in
|
||||
*Alacritty*)
|
||||
@@ -71,16 +68,6 @@ while read -r m special; do
|
||||
esac
|
||||
|
||||
wait_for_screensaver_window
|
||||
done < <(hyprctl monitors -j | jq -r '.[] | "\(.name) \(.specialWorkspace.name)"')
|
||||
done
|
||||
|
||||
hypr_focus_monitor "$focused"
|
||||
|
||||
# Emptying a special workspace focuses its monitor, so the last screensaver to close would take focus
|
||||
# with it. Hand focus back to the monitor that had it once they are all gone.
|
||||
# The class goes through the environment, since in argv the screensaver's pgrep and pkill would match it.
|
||||
(
|
||||
while hyprctl clients -j | SCREENSAVER=org.omarchy.screensaver jq -e 'any(.[]; .mapped and .class == env.SCREENSAVER)' >/dev/null; do
|
||||
IFS= read -r -u "$events" line || exit
|
||||
done
|
||||
hypr_focus_monitor "$focused"
|
||||
) >/dev/null 2>&1 &
|
||||
@@ -1,12 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Launch a terminal in the active terminal's current directory
|
||||
# omarchy:args=[--pid=<terminal-pid>] [command...]
|
||||
# omarchy:args=[command...]
|
||||
|
||||
pid=""
|
||||
if [[ ${1:-} == --pid=* ]]; then
|
||||
pid=${1#--pid=}
|
||||
shift
|
||||
fi
|
||||
|
||||
exec setsid uwsm-app -- xdg-terminal-exec --dir="$(omarchy-cmd-terminal-cwd "$pid")" "$@"
|
||||
exec setsid uwsm-app -- xdg-terminal-exec --dir="$(omarchy-cmd-terminal-cwd)" "$@"
|
||||
@@ -3,15 +3,11 @@
|
||||
# omarchy:summary=Launch a URL as a web app in the default supported browser
|
||||
# omarchy:args=<url>
|
||||
|
||||
browser=$(omarchy-cmd-default-browser)
|
||||
browser=$(xdg-settings get default-web-browser)
|
||||
|
||||
case $browser in
|
||||
google-chrome* | brave* | microsoft-edge* | opera* | vivaldi* | helium*) ;;
|
||||
*) browser="chromium.desktop" ;;
|
||||
esac
|
||||
|
||||
browser_exec=$(sed -n 's/^Exec=\([^ ]*\).*/\1/p' {~/.local,~/.nix-profile,/usr}/share/applications/$browser 2>/dev/null | head -1)
|
||||
|
||||
if ! omarchy-cmd-browser-handoff "$browser_exec" --app="$1" "${@:2}"; then
|
||||
exec setsid uwsm-app -- $browser_exec --app="$1" "${@:2}"
|
||||
fi
|
||||
exec setsid uwsm-app -- $(sed -n 's/^Exec=\([^ ]*\).*/\1/p' {~/.local,~/.nix-profile,/usr}/share/applications/$browser 2>/dev/null | head -1) --app="$1" "${@:2}"
|
||||
+19
-126
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Open a generic image selector menu
|
||||
# omarchy:args=[--selected <image>] [--print-name] [--show-labels] [--filterable] [--lazy-thumbnails] [--preload] [--cache-only] [--print-rows] <image-dir>...
|
||||
# omarchy:args=[--selected <image>] [--print-name] [--show-labels] [--filterable] [--lazy-thumbnails] [--preload] [--cache-only] <image-dir>...
|
||||
|
||||
selected_image=""
|
||||
print_name=false
|
||||
@@ -11,11 +11,10 @@ lazy_thumbnails=false
|
||||
prepare_only=false
|
||||
preload=false
|
||||
cache_only=false
|
||||
print_rows=false
|
||||
image_dirs=()
|
||||
|
||||
usage() {
|
||||
echo "Usage: omarchy-menu-images [--selected <image>] [--print-name] [--show-labels] [--filterable] [--lazy-thumbnails] [--preload] [--cache-only] [--print-rows] <image-dir>..."
|
||||
echo "Usage: omarchy-menu-images [--selected <image>] [--print-name] [--show-labels] [--filterable] [--lazy-thumbnails] [--preload] [--cache-only] <image-dir>..."
|
||||
}
|
||||
|
||||
while (( $# > 0 )); do
|
||||
@@ -57,10 +56,6 @@ while (( $# > 0 )); do
|
||||
cache_only=true
|
||||
shift
|
||||
;;
|
||||
--print-rows)
|
||||
print_rows=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
@@ -80,9 +75,8 @@ fi
|
||||
selection_file=$(mktemp)
|
||||
done_file=$(mktemp)
|
||||
pending_file=$(mktemp)
|
||||
pending_video_file=$(mktemp)
|
||||
rm -f "$done_file"
|
||||
trap 'rm -f "$selection_file" "$done_file" "$pending_file" "$pending_video_file"' EXIT
|
||||
trap 'rm -f "$selection_file" "$done_file" "$pending_file"' EXIT
|
||||
|
||||
image_dirs_env=""
|
||||
for dir in "${image_dirs[@]}"; do
|
||||
@@ -117,8 +111,8 @@ cache_key=$(printf '%s' "$image_dirs_env" | md5sum | cut -d ' ' -f 1)
|
||||
rows_cache_file="$cache_dir/$cache_key.rows"
|
||||
rows_signature_file="$cache_dir/$cache_key.signature"
|
||||
rows_fast_signature_file="$cache_dir/$cache_key.fast-signature"
|
||||
rows_signature="v4"$'\n'
|
||||
rows_fast_signature="v3"$'\n'
|
||||
rows_signature="v3"$'\n'
|
||||
rows_fast_signature="v2"$'\n'
|
||||
rows_cacheable=true
|
||||
rows_cache_hit=false
|
||||
image_files=()
|
||||
@@ -139,25 +133,17 @@ else
|
||||
image_files+=("$image")
|
||||
image_signature=$(stat -Lc '%s:%Y' "$image") || continue
|
||||
rows_signature+="$image:$image_signature"$'\n'
|
||||
done < <(find -L "$dir" -maxdepth 1 -type f \
|
||||
\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \
|
||||
-o -iname '*.mp4' -o -iname '*.m4v' -o -iname '*.mov' -o -iname '*.webm' -o -iname '*.mkv' -o -iname '*.avi' \) \
|
||||
-print0 2>/dev/null | sort -z)
|
||||
done < <(find -L "$dir" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) -print0 2>/dev/null | sort -z)
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
is_video_path() {
|
||||
[[ ${1,,} =~ \.(mp4|m4v|mov|webm|mkv|avi)$ ]]
|
||||
}
|
||||
|
||||
generate_thumbnail() {
|
||||
local image="$1"
|
||||
local thumbnail="$2"
|
||||
local lock="$thumbnail.lock"
|
||||
local lock_fd
|
||||
local tmp="$thumbnail.$$.jpg"
|
||||
local thumbnail_command
|
||||
|
||||
# Older releases used directories as locks, which could survive a killed
|
||||
# generator and block this thumbnail forever. Only reap aged ones, so a
|
||||
@@ -175,29 +161,13 @@ generate_thumbnail() {
|
||||
|
||||
[[ -f $thumbnail ]] && return
|
||||
|
||||
# Callers fan out one generator per file, so keep each image conversion
|
||||
# single-threaded. ffmpegthumbnailer provides a still preview for videos.
|
||||
if is_video_path "$image"; then
|
||||
# Videos are generated before the picker opens, so one unreadable or
|
||||
# stalled file must not hold it shut. A failed run drops the row.
|
||||
thumbnail_command=(timeout -k 5 10 ffmpegthumbnailer -i "$image" -o "$tmp" -s 1536 -q 8)
|
||||
else
|
||||
thumbnail_command=(env VIPS_CONCURRENCY=1 vipsthumbnail "$image" --size 1536x864 --smartcrop=centre --path "$tmp[Q=82,strip]")
|
||||
fi
|
||||
|
||||
# Close the lock fd for the converter: an orphaned or hung child must not
|
||||
# keep holding the lock after this shell is killed.
|
||||
if "${thumbnail_command[@]}" {lock_fd}>&-; then
|
||||
# Callers fan out one generator per image, so keep each vips single-threaded.
|
||||
# Close the lock fd for vips: an orphaned or hung vips must not keep holding
|
||||
# the lock after this shell is killed.
|
||||
if VIPS_CONCURRENCY=1 vipsthumbnail "$image" --size 1536x864 --smartcrop=centre --path "$tmp[Q=82,strip]" {lock_fd}>&-; then
|
||||
mv -f "$tmp" "$thumbnail"
|
||||
else
|
||||
status=$?
|
||||
rm -f "$tmp" "$thumbnail"
|
||||
# Remember a video the converter rejected, so it costs nothing on the next
|
||||
# open. The key covers size and mtime, so a repaired file starts clean. A
|
||||
# timeout is left to retry: the machine may only have been busy.
|
||||
if is_video_path "$image" && (( status != 124 && status != 137 )); then
|
||||
: >"$thumbnail.failed"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -216,94 +186,30 @@ thumbnail_for() {
|
||||
thumbnail="$cache_dir/$hash.jpg"
|
||||
|
||||
if [[ ! -f $thumbnail ]]; then
|
||||
# A video that already failed to convert has no row to offer. Hand the
|
||||
# marker back so the caller can keep the rows uncached over its absence.
|
||||
if is_video_path "$image" && [[ -f $thumbnail.failed ]]; then
|
||||
printf '%s' "$thumbnail.failed"
|
||||
return
|
||||
fi
|
||||
|
||||
# A lazy row stands in with the media file itself, which the picker draws
|
||||
# with an Image -- fine for a picture, blank for a video. Videos take the
|
||||
# queue instead, which also keeps them under its narrower fan out.
|
||||
if [[ $lazy_thumbnails == true && $cache_only != true ]] && ! is_video_path "$image"; then
|
||||
if [[ $lazy_thumbnails == true && $cache_only != true ]]; then
|
||||
rows_cacheable=false
|
||||
|
||||
if [[ $prepare_only != true ]]; then
|
||||
printf '%s\0%s\0' "$image" "$thumbnail" >>"$pending_file"
|
||||
generate_thumbnail "$image" "$thumbnail" >/dev/null 2>&1 &
|
||||
fi
|
||||
|
||||
printf '%s' "$image"
|
||||
return
|
||||
fi
|
||||
|
||||
if is_video_path "$image"; then
|
||||
printf '%s\0%s\0' "$image" "$thumbnail" >>"$pending_video_file"
|
||||
else
|
||||
printf '%s\0%s\0' "$image" "$thumbnail" >>"$pending_file"
|
||||
fi
|
||||
printf '%s\0%s\0' "$image" "$thumbnail" >>"$pending_file"
|
||||
fi
|
||||
|
||||
printf '%s' "$thumbnail"
|
||||
}
|
||||
|
||||
# Each vips run is single-threaded. Eager preparation can fill every core;
|
||||
# lazy preparation leaves CPU and I/O capacity for the interactive shell.
|
||||
# ffmpegthumbnailer leaves FFmpeg's automatic threading on, so a full-width fan
|
||||
# out of those would put a codec thread pool on every core at once.
|
||||
# Generate every queued thumbnail at once; each vips run is single-threaded.
|
||||
drain_pending_thumbnails() {
|
||||
local video_jobs image_jobs pending_fd queue_file queue_lock_fd worker_lock_fd
|
||||
[[ -s $pending_file ]] || return 0
|
||||
|
||||
export -f generate_thumbnail is_video_path
|
||||
|
||||
if [[ -s $pending_file ]]; then
|
||||
if [[ $lazy_thumbnails == true && $cache_only != true ]]; then
|
||||
image_jobs=$(( $(nproc) / 2 ))
|
||||
(( image_jobs > 0 )) || image_jobs=1
|
||||
(( image_jobs > 2 )) && image_jobs=2
|
||||
# Publish jobs before trying to own the pool. A contended refresh leaves
|
||||
# its jobs for the current owner instead of throwing its queue away.
|
||||
queue_file="$rows_cache_file.thumbnails.pending"
|
||||
exec {queue_lock_fd}>"$rows_cache_file.thumbnails.queue.lock" || return
|
||||
flock "$queue_lock_fd" || return
|
||||
cat "$pending_file" >>"$queue_file"
|
||||
exec {queue_lock_fd}>&-
|
||||
(
|
||||
exec {worker_lock_fd}>"$rows_cache_file.thumbnails.lock"
|
||||
flock -n "$worker_lock_fd" || exit 0
|
||||
exec {queue_lock_fd}>"$rows_cache_file.thumbnails.queue.lock" || exit 1
|
||||
while true; do
|
||||
flock "$queue_lock_fd" || exit 1
|
||||
if [[ ! -s $queue_file ]]; then
|
||||
# Release ownership while publishing is still locked. A producer
|
||||
# arriving after the empty check can then start the next pool.
|
||||
exec {worker_lock_fd}>&-
|
||||
exec {queue_lock_fd}>&-
|
||||
break
|
||||
fi
|
||||
# Unlink the batch under the queue lock, then drain its open fd.
|
||||
# Producers can publish the next batch while conversions run.
|
||||
exec {pending_fd}<"$queue_file" || exit 1
|
||||
rm -f "$queue_file"
|
||||
flock -u "$queue_lock_fd"
|
||||
# Converters must not inherit the publication lock's descriptor.
|
||||
nice -n 10 ionice -c 3 xargs -0 -n 2 -P "$image_jobs" \
|
||||
bash -c 'generate_thumbnail "$1" "$2"' _ <&"$pending_fd" {queue_lock_fd}>&-
|
||||
exec {pending_fd}<&-
|
||||
done
|
||||
) >/dev/null 2>&1 &
|
||||
else
|
||||
xargs -a "$pending_file" -0 -n 2 -P "$(nproc)" \
|
||||
bash -c 'generate_thumbnail "$1" "$2"' _ >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -s $pending_video_file ]]; then
|
||||
video_jobs=$(( $(nproc) / 4 ))
|
||||
(( video_jobs > 0 )) || video_jobs=1
|
||||
xargs -a "$pending_video_file" -0 -n 2 -P "$video_jobs" \
|
||||
bash -c 'generate_thumbnail "$1" "$2"' _ >/dev/null 2>&1 || true
|
||||
fi
|
||||
export -f generate_thumbnail
|
||||
xargs -a "$pending_file" -0 -n 2 -P "$(nproc)" \
|
||||
bash -c 'generate_thumbnail "$1" "$2"' _ >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
if [[ $rows_cache_hit != true && -f $rows_cache_file && -f $rows_signature_file ]] && cmp -s "$rows_signature_file" <(printf '%s' "$rows_signature"); then
|
||||
@@ -313,12 +219,6 @@ elif [[ $rows_cache_hit != true ]]; then
|
||||
for image in "${image_files[@]}"; do
|
||||
thumbnail=$(thumbnail_for "$image")
|
||||
[[ -n $thumbnail ]] || continue
|
||||
# Cached rows are trusted on the directory's mtime alone, which a file
|
||||
# repaired in place never changes. Leave them uncached instead.
|
||||
if [[ $thumbnail == *.failed ]]; then
|
||||
rows_cacheable=false
|
||||
continue
|
||||
fi
|
||||
if [[ $lazy_thumbnails == true && $cache_only != true && $thumbnail == $image ]]; then
|
||||
rows_cacheable=false
|
||||
fi
|
||||
@@ -332,7 +232,7 @@ elif [[ $rows_cache_hit != true ]]; then
|
||||
|
||||
drain_pending_thumbnails
|
||||
|
||||
if [[ -s $pending_file || -s $pending_video_file ]]; then
|
||||
if [[ -s $pending_file ]]; then
|
||||
pruned=""
|
||||
while IFS=$'\t' read -r row_image row_thumbnail; do
|
||||
if [[ ! -e $row_thumbnail ]]; then
|
||||
@@ -371,13 +271,6 @@ if [[ $cache_only == true || $prepare_only == true ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# The shell keeps a copy of these rows so its picker can open without waiting
|
||||
# on this script, and reads them back from stdout to refresh that copy.
|
||||
if [[ $print_rows == true ]]; then
|
||||
printf '%s\n' "$rows"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Image rows can contain newlines and tabs, which don't survive positional
|
||||
# shell IPC arguments. Base64-encode for transit; the ImagePicker plugin
|
||||
# Qt.atob()s on the other side.
|
||||
|
||||
@@ -210,9 +210,6 @@ hl = setmetatable({
|
||||
elseif type(bind_dispatcher) == "string" and bind_dispatcher ~= "" then
|
||||
kind = "exec"
|
||||
arg = bind_dispatcher
|
||||
elseif o and o.bind_commands and o.bind_commands[bind_dispatcher] then
|
||||
kind = "exec"
|
||||
arg = o.bind_commands[bind_dispatcher]
|
||||
end
|
||||
|
||||
print(table.concat({ tostring(modmask), opts.description, key, kind, arg }, "\t"))
|
||||
@@ -533,7 +530,7 @@ output_binding_records_uncached() {
|
||||
|
||||
keybindings_cache_key() {
|
||||
{
|
||||
printf 'v14\n'
|
||||
printf 'v13\n'
|
||||
hyprctl devices 2>/dev/null | grep -F 'active keymap:'
|
||||
hyprctl binds 2>/dev/null
|
||||
} | sha256sum | awk '{ print $1 }'
|
||||
|
||||
@@ -12,38 +12,8 @@ package=$1
|
||||
command=${2:-$1}
|
||||
bin=${3:-$command}
|
||||
|
||||
# The command name becomes a file name under ~/.local/bin, so a slash in it
|
||||
# writes the wrapper somewhere else and the rm below deletes somewhere else. A
|
||||
# leading dot hides it or walks up, and a leading dash makes a name that reads
|
||||
# as an option to whatever picks it up. Checked before anything is removed or
|
||||
# written, and kept to those shapes so package names like npm:playwright still
|
||||
# stand in for the command name.
|
||||
case "$command" in
|
||||
*/* | .* | -* | *[[:cntrl:]]*)
|
||||
echo "omarchy-mise-install: '$command' is not usable as a command name" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
|
||||
# The heredoc below is unquoted, so whatever these hold is written into the
|
||||
# wrapper as shell source. Quote them the way omarchy-install-and-launch does, so
|
||||
# a package name carrying shell characters stays one argument instead of running.
|
||||
printf -v package_arg '%q' "$package"
|
||||
printf -v bin_arg '%q' "$bin"
|
||||
|
||||
# Keep values that are inert inside double quotes in the form existing migrations
|
||||
# recognize, including mise backend options that %q would unnecessarily escape.
|
||||
case "$package" in
|
||||
*'$'* | *'`'* | *'"'* | *'\'* | *'!'* | *[[:cntrl:]]*) ;;
|
||||
*) package_arg="\"$package\"" ;;
|
||||
esac
|
||||
case "$bin" in
|
||||
*'$'* | *'`'* | *'"'* | *'\'* | *'!'* | *[[:cntrl:]]*) ;;
|
||||
*) bin_arg="\"$bin\"" ;;
|
||||
esac
|
||||
|
||||
# These tools install and upgrade on first run, so mise's release cooldown would
|
||||
# hold a new version back for days after it ships. Exported rather than set on
|
||||
# the install line alone, so resolving the version to execute agrees with the
|
||||
@@ -52,8 +22,8 @@ rm -f "$HOME/.local/bin/$command"
|
||||
cat >"$HOME/.local/bin/$command" <<EOF
|
||||
#!/bin/bash
|
||||
export MISE_MINIMUM_RELEASE_AGE=0
|
||||
mise use -g --quiet $package_arg || exit 1
|
||||
exec mise x $package_arg -- $bin_arg "\$@"
|
||||
mise use -g --quiet "$package" || exit 1
|
||||
exec mise x "$package" -- "$bin" "\$@"
|
||||
EOF
|
||||
|
||||
chmod +x "$HOME/.local/bin/$command"
|
||||
@@ -22,9 +22,7 @@ if [[ -z $interface ]]; then
|
||||
# menu's visibility gate describe. Fall back to the first connected Wi-Fi
|
||||
# device. nmcli localizes state names, so pin the locale, and the prefix
|
||||
# match accepts states like "connected (externally)".
|
||||
if ! route_device=$(ip route get 1.1.1.1 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i == "dev") { print $(i + 1); exit } }'); then
|
||||
route_device=""
|
||||
fi
|
||||
route_device=$(ip route get 1.1.1.1 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i == "dev") { print $(i + 1); exit } }')
|
||||
if [[ -n $route_device && -d /sys/class/net/$route_device/wireless ]]; then
|
||||
interface=$route_device
|
||||
else
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Run OpenClaw's setup wizard the way Omarchy needs it: in the terminal, installing the gateway as a user service, and returning when it is done.
|
||||
|
||||
# Not bare `openclaw onboard`: as of 2026.9.1 that is the guided flow, which
|
||||
# ends by running a foreground gateway and handing off to a browser tab, and
|
||||
# never returns. --install-daemon keeps it to the classic wizard (minimal
|
||||
# prompts with --flow quickstart) that installs the gateway service, and
|
||||
# --skip-ui drops its closing Control UI/TUI prompt since whoever called this
|
||||
# opens one next.
|
||||
#
|
||||
# That classic wizard has one wrinkle: with --skip-ui it prints "Onboarding
|
||||
# complete" and then never exits. Upstream only calls exit(0) when it launched
|
||||
# the TUI, and the model sign-in leaves an open socket that keeps the process
|
||||
# alive otherwise. So this script watches for the gateway to answer and then
|
||||
# stops the wizard. That is safe because every prompt in the quickstart flow
|
||||
# runs before the gateway service is installed and reachable: by the time the
|
||||
# dashboard answers, only the closing notes are left.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
wizard=(openclaw onboard --flow quickstart --install-daemon --skip-ui)
|
||||
config=$HOME/.openclaw/openclaw.json
|
||||
settle_seconds=${OMARCHY_OPENCLAW_ONBOARD_SETTLE_SECONDS:-3}
|
||||
# Counted from the moment the config exists, i.e. once the wizard has applied
|
||||
# setup; the prompts before that take as long as the user takes.
|
||||
gateway_timeout=${OMARCHY_OPENCLAW_ONBOARD_GATEWAY_TIMEOUT:-180}
|
||||
|
||||
# Probes never get the terminal. The wizard writes its config while prompts
|
||||
# remain, a probe of a gateway not yet up then hangs until the timeout, and
|
||||
# OpenClaw's CLI takes a terminal on stdin out of raw mode as it is killed,
|
||||
# which leaves the wizard's next prompt echoing keys instead of reading them.
|
||||
gateway_answers() {
|
||||
timeout 10 openclaw dashboard --json </dev/null 2>/dev/null | jq -e '.ok == true' >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# A gateway already answering means OpenClaw is set up, and this run must not
|
||||
# read its own success off state that predates it: the wizard's repair pass
|
||||
# would be stopped mid-prompt the moment the watcher looked. Nothing to do.
|
||||
if [[ -f $config ]] && gateway_answers; then
|
||||
echo "OpenClaw is already set up and its gateway is running." >&2
|
||||
echo "To change providers or settings, run: openclaw onboard --classic" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Marks when this run began, so a config left behind by an earlier, incomplete
|
||||
# setup is not mistaken for this run having applied its own: the gateway
|
||||
# deadline below must not start ticking while the user is still at prompts.
|
||||
started=$(mktemp)
|
||||
trap 'rm -f "$started"' EXIT
|
||||
|
||||
# Backgrounded so this script can watch it, but with the terminal kept as its
|
||||
# stdin (bash would otherwise hand a background job /dev/null). Job control is
|
||||
# off in a script, so it stays in the terminal's foreground process group and
|
||||
# reads from it freely. Ctrl-C does not reach it directly, though: bash starts
|
||||
# async children with SIGINT ignored when job control is off, so the INT trap
|
||||
# below is what turns Ctrl-C into the wizard's exit.
|
||||
"${wizard[@]}" <&0 &
|
||||
wizard_pid=$!
|
||||
|
||||
stop_wizard() {
|
||||
kill -TERM "$wizard_pid" 2>/dev/null || true
|
||||
}
|
||||
# Any signal at this script, whether Ctrl-C from the terminal or a kill aimed
|
||||
# at its pid alone, takes the wizard down with it rather than leaving it
|
||||
# running unwatched.
|
||||
trap 'stop_wizard' INT TERM HUP
|
||||
|
||||
# Whether this run has applied setup: the config exists and is not older than
|
||||
# the run itself.
|
||||
config_applied() {
|
||||
[[ -f $config && ! $started -nt $config ]]
|
||||
}
|
||||
|
||||
# Whether this run's gateway is up: setup applied, and the process answering
|
||||
# on the gateway's port is the main process of the service the wizard
|
||||
# installs. Not the dashboard alone: with no config on disk `openclaw
|
||||
# dashboard --json` still probes the default loopback port, so a gateway left
|
||||
# behind by something else (an earlier guided onboarding's foreground
|
||||
# gateway, say) would read as this run's success while the user is still at
|
||||
# the first prompt. And not the unit being active either: its Type=simple
|
||||
# counts it active from the fork, before it has found the port taken by such
|
||||
# an orphan, and the app would then open on the orphan rather than the
|
||||
# service this run installed.
|
||||
gateway_ready() {
|
||||
config_applied || return 1
|
||||
local json port listener main_pid
|
||||
json=$(timeout 10 openclaw dashboard --json </dev/null 2>/dev/null) || return 1
|
||||
jq -e '.ok == true' <<<"$json" >/dev/null 2>&1 || return 1
|
||||
port=$(jq -r '.port // empty' <<<"$json" 2>/dev/null)
|
||||
[[ -n $port ]] || return 1
|
||||
listener=$(ss -ltnpH "sport = :$port" 2>/dev/null | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | head -1)
|
||||
main_pid=$(systemctl --user show -p MainPID --value openclaw-gateway.service 2>/dev/null)
|
||||
[[ -n $listener && -n $main_pid && $main_pid != 0 && $listener == "$main_pid" ]]
|
||||
}
|
||||
|
||||
stopped=false
|
||||
timed_out=false
|
||||
config_seen_at=
|
||||
while kill -0 "$wizard_pid" 2>/dev/null; do
|
||||
sleep 2
|
||||
if gateway_ready; then
|
||||
# Let the outro finish printing, then end the process the wizard leaves
|
||||
# running.
|
||||
sleep "$settle_seconds"
|
||||
stop_wizard
|
||||
stopped=true
|
||||
break
|
||||
fi
|
||||
config_applied || continue
|
||||
: "${config_seen_at:=$SECONDS}"
|
||||
if (( SECONDS - config_seen_at >= gateway_timeout )); then
|
||||
# Setup was applied but the service never came up (port taken, unit
|
||||
# failing, ...): the wizard would sit in its never-exiting state forever,
|
||||
# and so would whoever is waiting on this script.
|
||||
stop_wizard
|
||||
timed_out=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
wait "$wizard_pid"
|
||||
rc=$?
|
||||
|
||||
if [[ $timed_out == true ]]; then
|
||||
echo "OpenClaw's gateway did not come up within ${gateway_timeout}s of setup finishing." >&2
|
||||
echo "Check it with: openclaw gateway status" >&2
|
||||
exit 1
|
||||
fi
|
||||
# A wizard stopped here after the gateway came up did its job. One that
|
||||
# exited on its own, including a user who chose "Skip for now" (no config,
|
||||
# non-zero), keeps its own exit code.
|
||||
[[ $stopped == true ]] && rc=0
|
||||
exit "$rc"
|
||||
+3
-3
@@ -7,15 +7,15 @@
|
||||
|
||||
if omarchy-pkg-missing "$@"; then
|
||||
if (( EUID == 0 )); then
|
||||
pacman -S --noconfirm --needed -- "$@" || exit 1
|
||||
pacman -S --noconfirm --needed "$@" || exit 1
|
||||
else
|
||||
sudo pacman -S --noconfirm --needed -- "$@" || exit 1
|
||||
sudo pacman -S --noconfirm --needed "$@" || exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
for pkg in "$@"; do
|
||||
# Secondary check to handle states where pacman doesn't actually register an error
|
||||
if ! pacman -Q -- "$pkg" &>/dev/null; then
|
||||
if ! pacman -Q "$pkg" &>/dev/null; then
|
||||
echo -e "\033[31mError: Package '$pkg' did not install\033[0m" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
# omarchy:args=<packages...>
|
||||
|
||||
if omarchy-pkg-missing "$@"; then
|
||||
yay -S --noconfirm --needed -- "$@" || exit 1
|
||||
yay -S --noconfirm --needed "$@" || exit 1
|
||||
fi
|
||||
|
||||
for pkg in "$@"; do
|
||||
# Secondary check to handle states where pacman doesn't actually register an error
|
||||
if ! pacman -Q -- "$pkg" &>/dev/null; then
|
||||
if ! pacman -Q "$pkg" &>/dev/null; then
|
||||
echo -e "\033[31mError: Package '$pkg' did not install\033[0m" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -24,7 +24,6 @@ if [[ -n $pkg_names ]]; then
|
||||
source omarchy-sudo-keepalive
|
||||
|
||||
echo "$pkg_names" | sed 's/^/aur\//' | tr '\n' ' ' | xargs yay -S --noconfirm
|
||||
code=$?
|
||||
sudo updatedb --prune-bind-mounts=no --add-prunepaths=/.snapshots
|
||||
omarchy-show-done $code
|
||||
sudo updatedb
|
||||
omarchy-show-done
|
||||
fi
|
||||
@@ -8,16 +8,9 @@ installed=()
|
||||
declare -A installed_exact=()
|
||||
declare -A selected=()
|
||||
|
||||
if ! installed_packages=$(pacman -Qq); then
|
||||
echo "Unable to query installed packages; no packages were removed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while IFS= read -r package_name; do
|
||||
if [[ -n $package_name ]]; then
|
||||
installed_exact[$package_name]=1
|
||||
fi
|
||||
done <<< "$installed_packages"
|
||||
installed_exact[$package_name]=1
|
||||
done < <(pacman -Qq)
|
||||
|
||||
for pkg in "$@"; do
|
||||
if [[ -n ${installed_exact[$pkg]} && -z ${selected[$pkg]} ]]; then
|
||||
|
||||
@@ -22,5 +22,5 @@ if [[ -n $pkg_names ]]; then
|
||||
|
||||
# Convert newline-separated selections to space-separated for pacman
|
||||
echo "$pkg_names" | tr '\n' ' ' | xargs sudo pacman -S --noconfirm
|
||||
omarchy-show-done $?
|
||||
omarchy-show-done
|
||||
fi
|
||||
@@ -4,7 +4,7 @@
|
||||
# omarchy:args=<packages...>
|
||||
|
||||
for pkg in "$@"; do
|
||||
if ! pacman -Q -- "$pkg" &>/dev/null; then
|
||||
if ! pacman -Q "$pkg" &>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# omarchy:args=<packages...>
|
||||
|
||||
for pkg in "$@"; do
|
||||
pacman -Q -- "$pkg" &>/dev/null || exit 1
|
||||
pacman -Q "$pkg" &>/dev/null || exit 1
|
||||
done
|
||||
|
||||
exit 0
|
||||
@@ -20,5 +20,5 @@ pkg_names=$(yay -Qqe | fzf "${fzf_args[@]}")
|
||||
if [[ -n $pkg_names ]]; then
|
||||
# Convert newline-separated selections to space-separated for yay
|
||||
echo "$pkg_names" | tr '\n' ' ' | xargs sudo pacman -Rns --noconfirm
|
||||
omarchy-show-done $?
|
||||
omarchy-show-done
|
||||
fi
|
||||
@@ -11,7 +11,7 @@ usage() {
|
||||
Usage: omarchy-provision-first-run [--force]
|
||||
|
||||
Run first-login user setup and notification hooks. By default this only runs
|
||||
once per user.
|
||||
once per user. Use --force to rerun the full sequence and refresh user setup.
|
||||
USAGE
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ done
|
||||
state_dir=~/.local/state/omarchy
|
||||
FIRST_RUN_DONE="first-run-user"
|
||||
if omarchy-done check "$FIRST_RUN_DONE" && (( force == 0 )); then
|
||||
echo "First-run already complete."
|
||||
echo "First-run already complete (rerun with --force to refresh)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
@@ -449,7 +449,7 @@ greeter_screen() {
|
||||
rows=$(stty size 2>/dev/null </dev/tty | awk '{print $1}')
|
||||
[[ $rows =~ ^[0-9]+$ ]] || rows=${LINES:-24}
|
||||
|
||||
tagline="Beautiful, Fun & Agentic Linux by DHH"
|
||||
tagline="Beautiful, Modern & Opinionated Linux by DHH"
|
||||
hint="Press Return to Start Setup"
|
||||
|
||||
printf '%s%s' "$HIDE_CURSOR" "$CLEAR"
|
||||
@@ -677,15 +677,11 @@ user_groups() {
|
||||
if [[ -f $PROVISIONING_DIR/groups ]]; then
|
||||
while IFS= read -r group; do
|
||||
[[ -n $group ]] || continue
|
||||
# Never replay old privileged group defaults. Docker is always opt-in.
|
||||
# Input is only retained when the factory image has one of the features
|
||||
# whose installer deliberately grants access to raw input devices.
|
||||
# Never grant docker at first boot, even if an older install recorded it
|
||||
# (or a factory snapshot predating the opt-in default carries it): the
|
||||
# docker group is root-equivalent. It is opt-in via
|
||||
# omarchy-setup-security-sudoless-docker.
|
||||
[[ $group == "docker" ]] && continue
|
||||
if [[ $group == "input" ]] &&
|
||||
! pacman -Qq xpadneo-dkms &>/dev/null &&
|
||||
! pacman -Qq ydotool &>/dev/null; then
|
||||
continue
|
||||
fi
|
||||
getent group "$group" >/dev/null || continue
|
||||
[[ ",$groups," == *",$group,"* ]] || groups+=",$group"
|
||||
done <"$PROVISIONING_DIR/groups"
|
||||
@@ -1013,7 +1009,7 @@ run_provisioning() {
|
||||
log_step "finalizing user"
|
||||
echo finalize >"$STATE_FILE"
|
||||
if ! finalize_user; then
|
||||
log_step "finalize-user failed (continuing)"
|
||||
log_step "finalize-user failed (continuing; user can retry after login)"
|
||||
touch "$FINALIZE_WARNING_FLAG"
|
||||
fi
|
||||
|
||||
@@ -1092,6 +1088,7 @@ run_setup() {
|
||||
clear_logo
|
||||
echo
|
||||
say --foreground 1 "User finalization reported errors (see $LOG_FILE)."
|
||||
say --foreground 1 "Run 'omarchy-provision-user --force' after login to retry."
|
||||
sleep 3
|
||||
fi
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ state_dir="$HOME/.local/state/omarchy"
|
||||
mkdir -p "$state_dir"
|
||||
|
||||
if omarchy-done check finalize-user && (( force == 0 )); then
|
||||
echo "User finalization already complete."
|
||||
echo "User finalization already complete (rerun with --force to refresh)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -84,7 +84,7 @@ fi
|
||||
# Dev-aware skill symlinks. Cannot live in /etc/skel because OMARCHY_PATH may
|
||||
# point at a dev checkout (omarchy dev link) where the target differs.
|
||||
# Loops every skill directory, so shipping a new one needs no edit here.
|
||||
mkdir -p ~/.agents/skills ~/.claude/skills ~/.codex/skills ~/.pi/agent/skills ~/.gemini/config/skills ~/.hermes/skills
|
||||
mkdir -p ~/.agents/skills ~/.claude/skills ~/.codex/skills ~/.pi/agent/skills ~/.gemini/config/skills
|
||||
for skill in "$OMARCHY_PATH"/default/agents/skills/*/; do
|
||||
skill=${skill%/}
|
||||
name=${skill##*/}
|
||||
@@ -93,14 +93,6 @@ for skill in "$OMARCHY_PATH"/default/agents/skills/*/; do
|
||||
ln -sfn "$skill" ~/.codex/skills/"$name"
|
||||
ln -sfn "$skill" ~/.pi/agent/skills/"$name"
|
||||
ln -sfn "$skill" ~/.gemini/config/skills/"$name"
|
||||
ln -sfn "$skill" ~/.hermes/skills/"$name"
|
||||
if [[ -d ~/.hermes/profiles ]]; then
|
||||
for profile in ~/.hermes/profiles/*/; do
|
||||
[[ -d $profile ]] || continue
|
||||
mkdir -p "$profile/skills"
|
||||
ln -sfn "$skill" "$profile/skills/$name"
|
||||
done
|
||||
fi
|
||||
done
|
||||
|
||||
mkdir -p ~/Downloads ~/Pictures ~/Videos ~/.config/gtk-3.0
|
||||
|
||||
+17
-55
@@ -1,64 +1,26 @@
|
||||
#!/bin/bash -p
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Overwrite the package configuration for /etc/pacman with the Omarchy default of using its dedicated mirrors and repositories, then update all packages.
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
if [[ $- != *p* ]]; then
|
||||
echo "Refusing an unsafe Bash startup." >&2
|
||||
exit 126
|
||||
fi
|
||||
|
||||
security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126
|
||||
source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126
|
||||
omarchy_security_require_privileged_bash_startup || exit 126
|
||||
set -e
|
||||
omarchy_security_sanitize_bash_environment "$0" "$@"
|
||||
omarchy_security_require_source_root "$0"
|
||||
# Channel switching calls this behind its own sanitized PATH and hands the
|
||||
# caller's original path over the same way the updater receives it.
|
||||
user_path=${OMARCHY_UPDATE_USER_PATH:-$PATH}
|
||||
unset OMARCHY_UPDATE_USER_PATH
|
||||
# Traps first, so a signal or failure during the entry revocation still
|
||||
# exits through the cleanup path.
|
||||
omarchy_security_install_sudo_cleanup_traps
|
||||
omarchy_security_revoke_sudo_timestamp || exit 1
|
||||
omarchy_security_enable_no_update_sudo
|
||||
|
||||
channel="${1:-stable}"
|
||||
if [[ $channel != "stable" && $channel != "rc" && $channel != "edge" ]]; then
|
||||
echo "Invalid channel: $channel" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Render the channel templates plus this machine's regional additions as the
|
||||
# caller, so root only copies finished files. The scratch directory goes on
|
||||
# every exit, including a failed render, a refused sudo prompt, or a signal;
|
||||
# revoking the sudo timestamp stays the last thing the exit path does.
|
||||
rendered=$(mktemp -d)
|
||||
cleanup_refresh() {
|
||||
local status=$?
|
||||
rm -rf "$rendered"
|
||||
omarchy_security_exit_with_revoked_sudo "$status"
|
||||
}
|
||||
trap cleanup_refresh EXIT
|
||||
"$OMARCHY_PATH/bin/omarchy-apply-pacman" --render "$channel" "$rendered"
|
||||
|
||||
sudo cp -f /etc/pacman.conf /etc/pacman.conf.bak
|
||||
sudo cp -f /etc/pacman.d/mirrorlist /etc/pacman.d/mirrorlist.bak
|
||||
echo "Setting channel to $channel"
|
||||
sudo cp -f "$rendered/pacman.conf" /etc/pacman.conf
|
||||
sudo cp -f "$rendered/mirrorlist" /etc/pacman.d/mirrorlist
|
||||
rm -rf "$rendered"
|
||||
|
||||
# Allow user customization of /etc/pacman.conf before the upgrade runs, so
|
||||
# custom repositories and IgnorePkg entries shape the downgrade-capable
|
||||
# transaction below. The hook is user code: it runs cold behind the no-update
|
||||
# wrapper, and the timestamp is revoked again afterwards so any authorization
|
||||
# the hook obtained for itself cannot carry into the transaction.
|
||||
omarchy_security_revoke_sudo_timestamp
|
||||
PATH="$OMARCHY_PATH/default/omarchy/sudo-no-update:$user_path" \
|
||||
"$OMARCHY_PATH/bin/omarchy-hook" pre-refresh-pacman
|
||||
omarchy_security_revoke_sudo_timestamp
|
||||
channel="${1:-stable}"
|
||||
|
||||
if [[ $channel != "stable" && $channel != "rc" && $channel != "edge" ]]; then
|
||||
echo "Error: Invalid channel '$channel'. Must be one of: stable, rc, edge"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Setting channel to $channel"
|
||||
echo
|
||||
|
||||
sudo cp -f "$OMARCHY_PATH/default/pacman/pacman-$channel.conf" /etc/pacman.conf
|
||||
sudo cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-$channel" /etc/pacman.d/mirrorlist
|
||||
|
||||
# Allow user customization of /etc/pacman.conf before the upgrade runs
|
||||
omarchy-hook pre-refresh-pacman
|
||||
|
||||
# Reset all package DBs and then update
|
||||
omarchy-update-pacman -Syyuu --noconfirm
|
||||
sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syyuu --noconfirm
|
||||
@@ -11,8 +11,8 @@ set -e
|
||||
omarchy-refresh-pacman
|
||||
|
||||
# Downgrade any packages to the stable setup
|
||||
omarchy-update-pacman -Suu --noconfirm
|
||||
sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Suu --noconfirm
|
||||
|
||||
# Ensure all packages are installed
|
||||
mapfile -t packages < <(grep -v '^#' "$OMARCHY_PATH/install/omarchy-base.packages" | grep -v '^$')
|
||||
omarchy-update-pacman -Syu --noconfirm --needed "${packages[@]}"
|
||||
sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm --needed "${packages[@]}"
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Remove the Claude desktop app along with its configuration and caches.
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
# -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /.
|
||||
set -euo pipefail
|
||||
|
||||
# The app rewrites ~/.config/Claude for as long as it runs, so a removal that
|
||||
# leaves it running deletes the directory and watches it come straight back.
|
||||
pkill -x claude-desktop || true
|
||||
|
||||
omarchy-pkg-drop claude-desktop
|
||||
|
||||
# Not ~/.claude, ~/.claude.json, or ~/.cache/claude-cli-nodejs: those belong
|
||||
# to the Claude Code CLI, which ships in a different package that survives
|
||||
# this one. The desktop app keeps its state in its own Electron directories.
|
||||
rm -rf \
|
||||
"$HOME/.config/Claude" \
|
||||
"$HOME/.cache/Claude"
|
||||
|
||||
echo ""
|
||||
echo "Claude has been removed."
|
||||
@@ -1,241 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Remove the Hermes desktop app along with the Hermes runtime it installed.
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
# -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /.
|
||||
set -euo pipefail
|
||||
|
||||
unit_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||
|
||||
# Only systemd's own word counts as "stopped": a non-zero exit from is-active
|
||||
# also covers an unreachable user manager, which says nothing about whether
|
||||
# the process is alive.
|
||||
unit_stopped() {
|
||||
local state
|
||||
state=$(systemctl --user is-active "$1" 2>/dev/null) || true
|
||||
[[ $state == "inactive" || $state == "failed" ]]
|
||||
}
|
||||
|
||||
# Hermes is closed here rather than asked to be: the agent in its terminal,
|
||||
# the desktop app and its helpers, a gateway run by hand -- every process
|
||||
# whose program, by its executable or by what it was started as, lives in the
|
||||
# runtime about to be deleted or in the package; for an interpreter that is
|
||||
# the script it runs, or Hermes's own package named with -m, so a gateway
|
||||
# started by hand as `python .../hermes` or `python -m hermes_cli.main` is
|
||||
# found too. Judged by the program alone, never by a later argument: an
|
||||
# editor opened on a file in the runtime is the user's. `check` refuses first if anything else has Hermes's files open, by
|
||||
# its executable, its working directory or a descriptor, that editor or a
|
||||
# shell sitting in ~/.hermes, so a refusal touches nothing; that
|
||||
# one is the user's to close, because removing an open SQLite WAL leaves a
|
||||
# live writer on a deleted generation. `stop` then ends Hermes's own and
|
||||
# refuses again over whatever is left.
|
||||
hermes_holders() {
|
||||
python3 - "$1" "$HOME" <<'PY'
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
mode, home = sys.argv[1], Path(sys.argv[2])
|
||||
roots = [str((home / relative).resolve()) for relative in ('.hermes', '.config/Hermes')]
|
||||
roots.append('/opt/hermes-desktop')
|
||||
runtime = [str((home / relative).resolve()) for relative in ('.hermes/hermes-agent', '.hermes/node', '.hermes/bin')]
|
||||
runtime.append('/opt/hermes-desktop')
|
||||
|
||||
def under(target, directories):
|
||||
target = target.removesuffix(' (deleted)')
|
||||
return any(target == directory or target.startswith(directory + '/') for directory in directories)
|
||||
|
||||
def processes():
|
||||
found = []
|
||||
for process in Path('/proc').iterdir():
|
||||
if not process.name.isdigit() or int(process.name) == os.getpid():
|
||||
continue
|
||||
try:
|
||||
if process.stat().st_uid != os.getuid():
|
||||
continue
|
||||
argv = [os.fsdecode(arg) for arg in (process / 'cmdline').read_bytes().split(b'\0')]
|
||||
program = []
|
||||
try:
|
||||
program.append(os.readlink(process / 'exe'))
|
||||
except OSError:
|
||||
pass
|
||||
touched = list(program)
|
||||
try:
|
||||
touched.append(os.readlink(process / 'cwd'))
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
for entry in (process / 'fd').iterdir():
|
||||
try:
|
||||
touched.append(os.readlink(entry))
|
||||
except OSError:
|
||||
pass
|
||||
except PermissionError:
|
||||
pass
|
||||
started_as = argv[:1]
|
||||
interpreter = bool(started_as) and re.fullmatch(r'(python[0-9.]*|node|bash|sh)', os.path.basename(started_as[0]))
|
||||
if interpreter:
|
||||
started_as = argv[:2]
|
||||
# Hermes's own package, run as a module from an activated venv.
|
||||
ours = any(under(target, runtime) for target in started_as + program) or \
|
||||
(bool(interpreter) and argv[1:3] and argv[1] == '-m' and argv[2].startswith('hermes_cli'))
|
||||
if ours or any(under(target, roots) for target in touched):
|
||||
try:
|
||||
name = (process / 'comm').read_text().strip()
|
||||
except OSError:
|
||||
name = '?'
|
||||
found.append((int(process.name), ours, name))
|
||||
except (FileNotFoundError, ProcessLookupError, PermissionError):
|
||||
continue
|
||||
return found
|
||||
|
||||
def named(entries):
|
||||
return ', '.join(f'{pid} {name}' for pid, ours, name in entries)
|
||||
|
||||
def refuse(entries):
|
||||
print('Something other than Hermes still has its files open (PIDs: ' + named(entries)
|
||||
+ '). Close it, then run the removal again.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
strangers = [entry for entry in processes() if not entry[1]]
|
||||
if strangers:
|
||||
refuse(strangers)
|
||||
if mode == 'check':
|
||||
sys.exit(0)
|
||||
|
||||
mine = [entry for entry in processes() if entry[1]]
|
||||
if mine:
|
||||
print('Stopping Hermes (PIDs: ' + named(mine) + ')...')
|
||||
for signum, patience in ((signal.SIGTERM, 10), (signal.SIGKILL, 3)):
|
||||
for pid, ours, name in mine:
|
||||
try:
|
||||
os.kill(pid, signum)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
deadline = time.monotonic() + patience
|
||||
while mine and time.monotonic() < deadline:
|
||||
time.sleep(0.2)
|
||||
mine = [entry for entry in processes() if entry[1]]
|
||||
if not mine:
|
||||
break
|
||||
if mine:
|
||||
print('Hermes did not stop (PIDs: ' + named(mine) + '). Close it, then run the removal again.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
left = processes()
|
||||
if left:
|
||||
refuse(left)
|
||||
PY
|
||||
}
|
||||
|
||||
hermes_holders check
|
||||
|
||||
# The gateway unit that upstream's `hermes gateway install` writes starts the
|
||||
# runtime about to be deleted, so it goes with it, and it is stopped before
|
||||
# the processes are, or systemd would start the gateway again the moment it
|
||||
# was killed. Judged by what the unit starts, as the processes are: a unit
|
||||
# whose ExecStart runs this runtime is its own whatever home it serves, and
|
||||
# one that runs a Hermes kept elsewhere is somebody else's arrangement even
|
||||
# when its home sits under ~/.hermes. A unit that will not stop aborts the
|
||||
# removal: dropping the package would strand a live gateway on deleted code
|
||||
# with no way to restart it cleanly.
|
||||
for unit_file in "$unit_dir"/hermes-gateway*.service; do
|
||||
[[ -f $unit_file ]] || continue
|
||||
grep '^ExecStart=' "$unit_file" | grep -qF "$HOME/.hermes/hermes-agent/" || continue
|
||||
unit=${unit_file##*/}
|
||||
if systemctl --user disable --now "$unit" 2>/dev/null || unit_stopped "$unit"; then
|
||||
# .bak is what `gateway install --force` leaves behind when it rewrites a
|
||||
# unit, so it goes with the unit.
|
||||
rm -f "$unit_file" "$unit_file.bak" "$unit_dir/default.target.wants/$unit"
|
||||
systemctl --user daemon-reload 2>/dev/null || true
|
||||
# A unit that had been failing stays listed as "not-found failed" after
|
||||
# its file is gone until its failed state is reset.
|
||||
systemctl --user reset-failed "$unit" 2>/dev/null || true
|
||||
else
|
||||
echo "Could not stop $unit; Hermes was not removed." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# The installer leaves a unit waiting to hand the app the Omarchy theme; it
|
||||
# runs Hermes itself to do so, so it is stopped before the sweep reaches it.
|
||||
systemctl --user stop omarchy-hermes-theme.service 2>/dev/null || true
|
||||
|
||||
hermes_holders stop
|
||||
omarchy-pkg-drop hermes-desktop
|
||||
|
||||
# Upstream's installer writes this when the runtime under ~/.hermes has landed,
|
||||
# whether Omarchy ran it for the app or the app's own first launch did, and it
|
||||
# is the only thing that tells that runtime apart from one the user installed
|
||||
# themselves -- the paths are the same either way. Without it the install never
|
||||
# got that far: a machine where the package landed but nothing set Hermes up
|
||||
# still has whatever was there before, and none of it is ours to delete unasked.
|
||||
if [[ -f $HOME/.hermes/hermes-agent/.hermes-bootstrap-complete ]]; then
|
||||
# The checkout and venv, its own uv, its own node. None of it is any use once
|
||||
# the app is gone, so it goes without asking; what the user made with the app
|
||||
# is a different question, answered below.
|
||||
rm -rf \
|
||||
"$HOME/.hermes/hermes-agent" \
|
||||
"$HOME/.hermes/bootstrap-cache" \
|
||||
"$HOME/.hermes/bin" \
|
||||
"$HOME/.hermes/node"
|
||||
|
||||
# Only the wrappers pointing into ~/.hermes, matched as a plain string: the
|
||||
# path carries a dot, so an unanchored pattern would also claim a wrapper
|
||||
# pointing at a sibling like ~/xhermes.
|
||||
for command in hermes hermes-agent hermes-acp; do
|
||||
wrapper="$HOME/.local/bin/$command"
|
||||
|
||||
if [[ -f $wrapper && ! -L $wrapper ]] && grep -qF "$HOME/.hermes" "$wrapper"; then
|
||||
rm -f "$wrapper"
|
||||
fi
|
||||
done
|
||||
|
||||
# When Hermes brought its own Node it symlinked these next to its own commands,
|
||||
# and they point at what we just deleted. Only the links into ~/.hermes: a
|
||||
# system Node, or someone else's, lives somewhere else entirely.
|
||||
for command in node npm npx; do
|
||||
link="$HOME/.local/bin/$command"
|
||||
|
||||
if [[ -L $link && $(readlink "$link") == "$HOME/.hermes"/* ]]; then
|
||||
rm -f "$link"
|
||||
fi
|
||||
done
|
||||
|
||||
fi
|
||||
|
||||
# What survives to here is the user's: the chats, memories and skills in
|
||||
# ~/.hermes, the connections and their encrypted tokens in ~/.config/Hermes.
|
||||
# Keeping them stays the default -- they are small, and finding them intact
|
||||
# after a reinstall is the better surprise -- but a removal meant to be
|
||||
# complete should not leave credentials behind either, so the choice is put in
|
||||
# front of the user with the size, default no. Asked whenever the directories
|
||||
# exist, marker or no marker: on a machine where the marker never appeared the
|
||||
# data came from the terminal CLI or an install the app never finished, and it
|
||||
# is still what removal is asked to clean up. Naming the paths keeps the
|
||||
# question honest there too -- ~/.hermes may still carry a runtime the app
|
||||
# never owned, a yes takes that with it, and saying so is the prompt's job.
|
||||
# Without a terminal to ask in, keeping everything is the answer.
|
||||
data_removed=false
|
||||
if [[ -d $HOME/.hermes || -d $HOME/.config/Hermes ]] && [[ -t 0 ]]; then
|
||||
# du answers non-zero when either directory is missing, and pipefail would
|
||||
# turn that into an aborted removal; the size is worth no such thing.
|
||||
size=$(du -shc "$HOME/.hermes" "$HOME/.config/Hermes" 2>/dev/null | tail -1 | cut -f1 || true)
|
||||
if gum confirm --default=false "Also delete ~/.hermes and ~/.config/Hermes ($size: chats, memories, skills, connections and tokens)?"; then
|
||||
hermes_holders stop
|
||||
rm -rf "$HOME/.hermes" "$HOME/.config/Hermes"
|
||||
data_removed=true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Hermes Desktop has been removed."
|
||||
if [[ $data_removed == true ]]; then
|
||||
echo "Its chats, memories, and settings in ~/.hermes and ~/.config/Hermes are gone too."
|
||||
elif [[ -d $HOME/.hermes || -d $HOME/.config/Hermes ]]; then
|
||||
echo "Your chats, memories, and skills are still in ~/.hermes,"
|
||||
echo "and your connections and settings in ~/.config/Hermes."
|
||||
fi
|
||||
@@ -1,88 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Remove the OpenClaw agent platform along with its gateway service and web app.
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
# -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /.
|
||||
set -euo pipefail
|
||||
|
||||
unit_dir="$HOME/.config/systemd/user"
|
||||
|
||||
# Only systemd's own word counts as "stopped": a non-zero exit from is-active
|
||||
# also covers an unreachable user manager, which says nothing about whether
|
||||
# the process is alive.
|
||||
unit_stopped() {
|
||||
local state
|
||||
state=$(systemctl --user is-active "$1" 2>/dev/null) || true
|
||||
[[ $state == inactive || $state == failed ]]
|
||||
}
|
||||
|
||||
# The user services OpenClaw installs for itself: the gateway through
|
||||
# onboarding, and the node host if the user ever paired this machine to
|
||||
# another gateway. OpenClaw writes them to $HOME/.config regardless of
|
||||
# XDG_CONFIG_HOME, so look exactly there; no unit means nothing registered.
|
||||
# Upstream's own teardown knows every piece its install wrote (unit file, the
|
||||
# default.target.wants enablement symlink, the reload), so prefer it while the
|
||||
# binary is still installed and fall back to doing the same by hand. A service
|
||||
# that will not stop aborts the removal: dropping the package would strand the
|
||||
# live process on deleted code with no way to restart it cleanly.
|
||||
for unit_file in "$unit_dir"/openclaw-gateway.service "$unit_dir"/openclaw-node.service; do
|
||||
[[ -f $unit_file ]] || continue
|
||||
unit=${unit_file##*/}
|
||||
role=${unit#openclaw-}
|
||||
role=${role%.service}
|
||||
if openclaw "$role" uninstall >/dev/null 2>&1 ||
|
||||
systemctl --user disable --now "$unit" 2>/dev/null ||
|
||||
unit_stopped "$unit"; then
|
||||
# .bak is what `gateway install --force` leaves behind when it rewrites a
|
||||
# unit, and .reconcile-*.bak what `openclaw update` leaves when it
|
||||
# refreshes one, so they go with the unit.
|
||||
rm -f "$unit_file" "$unit_file.bak" "$unit_file".reconcile-*.bak "$unit_dir/default.target.wants/$unit"
|
||||
systemctl --user daemon-reload 2>/dev/null || true
|
||||
# A unit that had been failing stays listed as "not-found failed" after
|
||||
# its file is gone until its failed state is reset.
|
||||
systemctl --user reset-failed "$unit" 2>/dev/null || true
|
||||
else
|
||||
echo "Could not stop $unit; OpenClaw was not removed." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
omarchy-pkg-drop openclaw
|
||||
|
||||
# The command on PATH goes, so nothing answers to openclaw. The OpenClaw it
|
||||
# pointed at stays in ~/.openclaw with the state below: its own updates may
|
||||
# have migrated that state past the packaged release, which could not open it
|
||||
# again, and a reinstall picks the kept copy back up instead of seeding one.
|
||||
if [[ -L $HOME/.local/bin/openclaw && $(readlink -- "$HOME/.local/bin/openclaw") == "$HOME/.openclaw/bin/openclaw" ]]; then
|
||||
rm -f "$HOME/.local/bin/openclaw"
|
||||
fi
|
||||
|
||||
# The web app launcher and icon omarchy-install-ai-openclaw created.
|
||||
rm -f "$HOME/.local/share/applications/OpenClaw.desktop"
|
||||
rm -f "$HOME/.local/share/icons/hicolor/256x256/apps/openclaw.png"
|
||||
gtk-update-icon-cache "$HOME/.local/share/icons/hicolor" &>/dev/null || true
|
||||
|
||||
# ~/.openclaw stays unless asked: the chats, memories, and credentials in
|
||||
# there are the user's agent, and finding them intact after a reinstall is the
|
||||
# better surprise. But it also holds the plugin runtimes and caches OpenClaw
|
||||
# downloads for itself, easily hundreds of megabytes, so the choice is put in
|
||||
# front of the user with the size rather than left silent. Without a terminal
|
||||
# to ask in, keeping it is the answer. The snapshots `openclaw update` takes
|
||||
# of that state sit beside it and go with it.
|
||||
state_removed=false
|
||||
if [[ -d $HOME/.openclaw && -t 0 ]]; then
|
||||
size=$(du -sh "$HOME/.openclaw" 2>/dev/null | cut -f1)
|
||||
if gum confirm --default=false "Also delete ~/.openclaw ($size: chats, memories, credentials, downloaded plugins, and OpenClaw itself)?"; then
|
||||
rm -rf "$HOME/.openclaw" "$HOME/.openclaw.update-captures"
|
||||
state_removed=true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "OpenClaw has been removed."
|
||||
if [[ $state_removed == true ]]; then
|
||||
echo "Its chats, memories, and settings in ~/.openclaw are gone too."
|
||||
elif [[ -d $HOME/.openclaw ]]; then
|
||||
echo "Your agent's chats, memories, and settings are still in ~/.openclaw."
|
||||
fi
|
||||
Loaded 100 of 677 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user