Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c668141e9c | ||
|
|
8f3ab025b8 | ||
|
|
28d5251e23 | ||
|
|
cbaab83a1e | ||
|
|
dc0d894584 | ||
|
|
ec73ba5d00 | ||
|
|
0534987009 | ||
|
|
b9657b6313 | ||
|
|
15b4928e11 | ||
|
|
4ea7ca2b8b | ||
|
|
f4d156c09a | ||
|
|
6ba4ceef57 | ||
|
|
3248aa55c2 | ||
|
|
7cc9057413 | ||
|
|
d57cec42d4 | ||
|
|
c5b59bcfff | ||
|
|
a9e1c01ee9 | ||
|
|
4064436eea | ||
|
|
2cdcafa5b5 | ||
|
|
461cbe9b9c | ||
|
|
256bfeaa75 | ||
|
|
36695fbe84 | ||
|
|
9d95fc7f01 | ||
|
|
e919a64014 | ||
|
|
866d994dcf | ||
|
|
cf459ba752 | ||
|
|
1fe83ff129 | ||
|
|
6a72d4236b | ||
|
|
7a35a8ee9b | ||
|
|
6d327ce83c | ||
|
|
f94c6db34d | ||
|
|
8d1a7ef00c | ||
|
|
6cc561b8d3 | ||
|
|
6cb5396dc7 | ||
|
|
27594ba3f5 | ||
|
|
be6c401200 | ||
|
|
d49dd939b7 | ||
|
|
68e3d7a2ec | ||
|
|
15d64a7025 | ||
|
|
82de446713 | ||
|
|
ebaad12947 | ||
|
|
cf6fedc9f5 | ||
|
|
fb257bf872 | ||
|
|
1cbe72e5dd | ||
|
|
b1cdec9e47 | ||
|
|
af6f64fa3a | ||
|
|
346e69e1ce | ||
|
|
0e1ce609d8 | ||
|
|
3cb3b2d20b | ||
|
|
f8e35ec654 | ||
|
|
f802ae107d | ||
|
|
6f8b62f04c | ||
|
|
9aa0545c2a | ||
|
|
ebd6480387 | ||
|
|
07579f5e94 | ||
|
|
ff4cf8afbc | ||
|
|
cf3d69c38d | ||
|
|
47ce81ebca | ||
|
|
2421e76fb8 | ||
|
|
bffc6a2a5d | ||
|
|
7678e5b0d8 | ||
|
|
be730884f6 | ||
|
|
a73a312e1b | ||
|
|
159163bb16 | ||
|
|
521779b114 | ||
|
|
51af7bf794 | ||
|
|
92c8f87730 | ||
|
|
3bb9867245 | ||
|
|
da0fe6d89f | ||
|
|
2645b82bcd | ||
|
|
9c1ec524cd | ||
|
|
880605f22b | ||
|
|
294c38a2f0 | ||
|
|
1082b7864d | ||
|
|
13f18b2cb7 | ||
|
|
2e989e35e5 | ||
|
|
c6d676f23c | ||
|
|
0f836c0deb | ||
|
|
07adef8a53 | ||
|
|
e713ff3166 | ||
|
|
c7af36d0aa | ||
|
|
b3028f9bd9 | ||
|
|
0f4abeec8e | ||
|
|
7b89780810 | ||
|
|
c0b593b349 | ||
|
|
7fa32bb98c | ||
|
|
2b1d3c4606 | ||
|
|
286b8c2b1c | ||
|
|
f2a2973de8 | ||
|
|
64c5c02f55 | ||
|
|
d0aefc1f79 | ||
|
|
1626405638 | ||
|
|
9b192256e7 | ||
|
|
2487fbcce1 | ||
|
|
9434f220f0 | ||
|
|
9e79b24342 | ||
|
|
6245965cb6 | ||
|
|
f6b4183e4f | ||
|
|
39c2797d3b | ||
|
|
8b979e0c4c | ||
|
|
5c2fb68c0a | ||
|
|
2d3826aff3 | ||
|
|
eb201d0bd3 | ||
|
|
ac1bdbfbf5 | ||
|
|
9f7cdf6983 | ||
|
|
1867dcaa50 | ||
|
|
33d7363c33 | ||
|
|
84336759cc | ||
|
|
5f0704a0f8 |
No files matched your search
@@ -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/
|
||||
@@ -9,26 +9,16 @@ matching guide before starting:
|
||||
- [`agents/skills/icon-font.md`](agents/skills/icon-font.md) - adding branded glyphs to `default/fonts/omarchy/omarchy.ttf`
|
||||
- [`agents/skills/acceptance-tests.md`](agents/skills/acceptance-tests.md) - writing or running graphical acceptance tests under `test/acceptance.d/`
|
||||
- [`agents/skills/visual-verification.md`](agents/skills/visual-verification.md) - verifying any change with a visual effect in the running UI
|
||||
- [`agents/skills/migrations.md`](agents/skills/migrations.md) - creating or changing migrations under `migrations/`
|
||||
|
||||
# Documentation Layout
|
||||
|
||||
Three documentation trees, split by genre and audience:
|
||||
|
||||
- `agents/skills/` - task procedure ("do this when doing X"), for anyone working on the codebase
|
||||
- `docs/` - reference on how the system is shaped (file layout, update pipeline, theming, shell architecture), for anyone working on the codebase; skills link here for depth
|
||||
- `manual/` - end-user documentation for using Omarchy, published; never codebase internals
|
||||
- [`docs/migrations.md`](docs/migrations.md) - creating or changing migrations under `migrations/`
|
||||
|
||||
# Style
|
||||
|
||||
- In markdown documents (`plans/`, `docs/`, `manual/`), write full lines — no hard wrapping at 80 columns; break only at structural boundaries like headings and list items
|
||||
- Two spaces for indentation, no tabs
|
||||
- Use bash 5 conditionals: use `[[ ]]` for string/file tests and `(( ))` for numeric tests
|
||||
- In `[[ ]]`, don't quote variables, but do quote string literals when comparing values (e.g., `[[ $branch == "dev" ]]`)
|
||||
- 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
|
||||
@@ -91,8 +81,7 @@ Exceptions are allowed for migration and package-helper scripts where the helper
|
||||
|
||||
# Menu
|
||||
|
||||
- The menu definition lives in `default/omarchy/omarchy-menu.jsonc`;
|
||||
[`docs/menu.md`](docs/menu.md) covers the schema, guards, and providers.
|
||||
- The menu definition lives in `default/omarchy/omarchy-menu.jsonc`.
|
||||
- Do not add `aliases` to new menu entries. Aliases are reserved for
|
||||
established alternate names users already type, kept for compatibility.
|
||||
|
||||
@@ -104,9 +93,7 @@ Exceptions are allowed for migration and package-helper scripts where the helper
|
||||
|
||||
# Tests
|
||||
|
||||
Run focused automated tests for the area you changed;
|
||||
[`docs/testing.md`](docs/testing.md) covers how the suites are shaped. Current
|
||||
test entry points:
|
||||
Run focused automated tests for the area you changed. Current test entry points:
|
||||
|
||||
- `./test/all` - aggregate runner for CLI and shell tests; it intentionally does not run graphical acceptance tests
|
||||
- `./test/cli` - CLI routing, command metadata, theme helpers, and safe dispatch coverage
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -15,7 +15,10 @@ Take a full-screen screenshot without opening the editor:
|
||||
omarchy capture screenshot fullscreen save
|
||||
```
|
||||
|
||||
The command writes to Omasnap's configured screenshot directory. Use `omarchy screenshot` for the interactive capture flow, which copies the capture and shows a timed preview. Use `omarchy screenshot --editor=overlay` to test annotation before output. Capture reference and candidate states as separate images when changing a layer-shell surface or layout, then compare both.
|
||||
The command prints the saved path and writes to the configured Pictures
|
||||
directory. Use `omarchy screenshot` for the interactive smart-region flow.
|
||||
Capture reference and candidate states as separate images when changing a
|
||||
layer-shell surface or layout, then compare both.
|
||||
|
||||
Record a short full-screen video for animation, transition, timing, capture, or
|
||||
screen-recording changes:
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Name=Disk Usage
|
||||
Exec=xdg-terminal-exec --app-id=TUI.float -e bash -c "dua i /"
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=disk-usage
|
||||
StartupNotify=true
|
||||
File renamed without changes.
+1
-28
@@ -26,8 +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[ascii]="Text drawn as ASCII art"
|
||||
GROUP_DESCRIPTIONS[agent]="AI coding agent usage data"
|
||||
GROUP_DESCRIPTIONS[audio]="Audio input and output controls"
|
||||
GROUP_DESCRIPTIONS[bar]="Omarchy shell bar layout and settings"
|
||||
GROUP_DESCRIPTIONS[battery]="Battery status helpers"
|
||||
@@ -40,7 +39,6 @@ GROUP_DESCRIPTIONS[channel]="Omarchy release channel management"
|
||||
GROUP_DESCRIPTIONS[clipboard]="Clipboard helpers"
|
||||
GROUP_DESCRIPTIONS[cmd]="Command and shortcut helpers"
|
||||
GROUP_DESCRIPTIONS[config]="System configuration helpers"
|
||||
GROUP_DESCRIPTIONS[crash]="Crash notification controls"
|
||||
GROUP_DESCRIPTIONS[debug]="Diagnostics and support logs"
|
||||
GROUP_DESCRIPTIONS[finalize]="Finalize user setup"
|
||||
GROUP_DESCRIPTIONS[default]="Default application selection"
|
||||
@@ -371,20 +369,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 +954,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
|
||||
|
||||
+3
-33
@@ -52,14 +52,14 @@ 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 has no approval prompts.
|
||||
case "$agent" in
|
||||
opencode)
|
||||
command=(opencode --auto)
|
||||
[[ -n ${prompt:-} ]] && command+=(--prompt "$prompt")
|
||||
;;
|
||||
agy)
|
||||
command=(agy --dangerously-skip-permissions)
|
||||
gemini)
|
||||
command=(gemini --yolo)
|
||||
[[ -n ${prompt:-} ]] && command+=(--prompt-interactive "$prompt")
|
||||
;;
|
||||
copilot)
|
||||
@@ -119,13 +119,6 @@ omp)
|
||||
command=(omp --auto-approve)
|
||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
||||
;;
|
||||
ori)
|
||||
# Ori is a harness launcher, and `ori code` is the agent it runs itself.
|
||||
# A prompt alone means one headless turn there, printed after the turn ends,
|
||||
# so --interactive is what seeds the session with it and keeps the window.
|
||||
command=(ori code)
|
||||
[[ -n ${prompt:-} ]] && command+=(--interactive --prompt "$prompt")
|
||||
;;
|
||||
pi)
|
||||
command=(pi)
|
||||
[[ -n ${prompt:-} ]] && command+=("$prompt")
|
||||
@@ -136,29 +129,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()
|
||||
@@ -16,7 +16,7 @@ flags=()
|
||||
only=()
|
||||
declare -A excluded
|
||||
|
||||
while (( $# > 0 )); do
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--force | --limits-only) flags+=("$1") ;;
|
||||
--except)
|
||||
@@ -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
|
||||
+3
-28
@@ -40,41 +40,16 @@ 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 [[ -x /usr/bin/fprintd-list ]] &&
|
||||
/usr/bin/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
|
||||
|
||||
-1217
File diff suppressed because it is too large.
Load diff
@@ -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 \
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Shared helpers for animating the About branding (source this, don't run it).
|
||||
# omarchy:group=branding
|
||||
# omarchy:name=about-animation
|
||||
# omarchy:hidden=true
|
||||
|
||||
# The About branding's animation: a sheen, and the frames that make it. *When*
|
||||
# those frames are written stays with the caller, because that is inseparable
|
||||
# from how the caller's window closes and resizes; this only says what to write,
|
||||
# and where — it is handed a logo and knows nothing about About beyond that.
|
||||
|
||||
# A band of light leans across the logo, rests, and leans across again. Two
|
||||
# columns per row puts it at 45 degrees on screen, where a cell is twice as tall
|
||||
# as it is wide.
|
||||
ESC=$'\e'
|
||||
# Bright white, so the band does not depend on how the terminal reads bold. Foot's
|
||||
# bold-text-in-bright brightens a bold regular colour into its bright counterpart,
|
||||
# which turns a bold green logo bright green — exactly the colour a bright green
|
||||
# band would have used, leaving a glint nobody can see. No regular colour
|
||||
# brightens into bright white, so this one shows either way.
|
||||
SHEEN_BAND="${ESC}[1m${ESC}[97m"
|
||||
SHEEN_SLANT=2
|
||||
SHEEN_HALF=2
|
||||
SHEEN_FRAME_SECONDS=0.025
|
||||
SHEEN_REST_TICKS=8
|
||||
# Re-measure the grid every half second, the cadence a still logo already cost,
|
||||
# rather than spawning a process per frame.
|
||||
SHEEN_POLL_FRAMES=20
|
||||
|
||||
# A frame is one string holding every row of the logo, positioned and coloured,
|
||||
# so a tick writes the whole logo at once and never shows it half drawn.
|
||||
compose_frame() {
|
||||
local centre=$1
|
||||
local row line length at from to frame=""
|
||||
|
||||
for (( row = 0; row < SHEEN_ROWS; row++ )); do
|
||||
line=${SHEEN_LINES[row]}
|
||||
length=${#line}
|
||||
|
||||
# Where the band crosses this row. Running off the right needs no clamp,
|
||||
# because a slice that starts past the end of a line is already empty, but a
|
||||
# negative offset would count from the end of it instead of off the left.
|
||||
at=$(( centre - row * SHEEN_SLANT ))
|
||||
from=$(( at - SHEEN_HALF ))
|
||||
from=$(( from < 0 ? 0 : from ))
|
||||
to=$(( at + SHEEN_HALF + 1 ))
|
||||
to=$(( to < 0 ? 0 : to ))
|
||||
|
||||
frame+="${ESC}[$((SHEEN_TOP + row));${SHEEN_LEFT}H"
|
||||
frame+="${SHEEN_BASE}${line:0:from}${SHEEN_BAND}${line:from:to - from}${SHEEN_BASE}${line:to}"
|
||||
done
|
||||
|
||||
SHEEN_COMPOSED=$frame
|
||||
}
|
||||
|
||||
# Builds every frame up front: a tick that recomputed a logo's worth of colour
|
||||
# spans in bash would not hold the frame rate, and the sweep is the same every
|
||||
# time. Leaves them in SHEEN_FRAMES, and answers whether this logo can be
|
||||
# animated at all — one it cannot put back exactly as it found it is one to leave
|
||||
# alone, because nothing on screen would say the difference.
|
||||
#
|
||||
# sheen_build <logo-file> <top-row> <left-column> <base-colour> <columns-available>
|
||||
sheen_build() {
|
||||
local file=$1 columns=$5
|
||||
SHEEN_TOP=$2
|
||||
SHEEN_LEFT=$3
|
||||
SHEEN_BASE=$4
|
||||
|
||||
SHEEN_LINES=()
|
||||
# A failing redirection reports itself before 2>/dev/null would apply, so order
|
||||
# it first: the caller's window must not get a shell error painted across it.
|
||||
mapfile -t SHEEN_LINES 2>/dev/null <"$file" || return 1
|
||||
SHEEN_ROWS=${#SHEEN_LINES[@]}
|
||||
(( SHEEN_ROWS > 0 )) || return 1
|
||||
|
||||
local row line width=0
|
||||
for (( row = 0; row < SHEEN_ROWS; row++ )); do
|
||||
line=${SHEEN_LINES[row]}
|
||||
|
||||
# A renderer substitutes $1 to $9 for colours, so a logo written with those is
|
||||
# not the text that reached the screen.
|
||||
[[ $line == *'$'* ]] && return 1
|
||||
|
||||
# These frames slice the row by character and a terminal draws it by column,
|
||||
# so one character has to be one cell — and everything that breaks that breaks
|
||||
# it here. A wide glyph, a combining mark or a joined emoji is not one cell; a
|
||||
# tab or an escape is one the renderer expanded itself; and a shell whose
|
||||
# locale is counting bytes is not counting characters at all.
|
||||
(( ${#line} == $(printf '%s' "$line" | LC_ALL=C.UTF-8 wc -L) )) || return 1
|
||||
|
||||
if (( ${#line} > width )); then
|
||||
width=${#line}
|
||||
fi
|
||||
done
|
||||
(( width > 0 && width <= columns )) || return 1
|
||||
|
||||
SHEEN_FRAMES=()
|
||||
local centre last=$(( width + SHEEN_ROWS * SHEEN_SLANT + SHEEN_HALF ))
|
||||
for (( centre = -SHEEN_HALF; centre <= last; centre++ )); do
|
||||
compose_frame "$centre"
|
||||
SHEEN_FRAMES+=("$SHEEN_COMPOSED")
|
||||
done
|
||||
}
|
||||
@@ -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
-58
@@ -1,25 +1,10 @@
|
||||
#!/bin/bash -p
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Set the Omarchy package channel.
|
||||
# omarchy:args=<stable|rc|edge|dev>
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
if [[ $- != *p* ]]; then
|
||||
echo "Refusing an unsafe Bash startup for channel switching." >&2
|
||||
exit 126
|
||||
fi
|
||||
security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126
|
||||
source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126
|
||||
omarchy_security_require_privileged_bash_startup || exit 126
|
||||
set -euo pipefail
|
||||
omarchy_security_sanitize_bash_environment "$0" "$@"
|
||||
omarchy_security_require_source_root "$0"
|
||||
user_path=$PATH
|
||||
# Traps first, so a signal or failure during the entry revocation still
|
||||
# exits through the cleanup path.
|
||||
omarchy_security_install_sudo_cleanup_traps
|
||||
omarchy_security_revoke_sudo_timestamp || exit 1
|
||||
omarchy_security_enable_no_update_sudo
|
||||
|
||||
usage() { echo "Usage: omarchy-channel-set [stable|rc|edge|dev]"; }
|
||||
fail() { echo "Error: $*" >&2; exit 1; }
|
||||
@@ -48,40 +33,12 @@ validate_dev_checkout() {
|
||||
}
|
||||
|
||||
link_dev_checkout() {
|
||||
local checkout="$1" required
|
||||
[[ -d $checkout/.git ]] || git clone https://github.com/omacom/omarchy.git "$checkout"
|
||||
|
||||
# Check the destination before changing /etc/omarchy.conf or sudo's path.
|
||||
# An existing checkout is not pulled automatically and may predate this policy.
|
||||
for required in bin/omarchy-security-functions bin/omarchy-update bin/omarchy-refresh-pacman default/omarchy/sudo-no-update/sudo; do
|
||||
if [[ ! -f $checkout/$required || ! -r $checkout/$required ||
|
||||
( $required != "bin/omarchy-security-functions" && ! -x $checkout/$required ) ]]; then
|
||||
fail "Update the checkout before switching to dev; missing required update support in $required."
|
||||
fi
|
||||
done
|
||||
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=""
|
||||
@@ -115,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
|
||||
@@ -126,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
|
||||
@@ -144,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
|
||||
@@ -170,7 +170,7 @@ main() {
|
||||
local length payload url
|
||||
|
||||
# Detached worker: this is what actually runs yt-dlp and fires notifications.
|
||||
if [[ ${1:-} == "--download" ]]; then
|
||||
if [[ "${1:-}" == "--download" ]]; then
|
||||
download_url "$2"
|
||||
fi
|
||||
|
||||
|
||||
@@ -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,73 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Silence crash notifications for one program, or list what is silenced
|
||||
# omarchy:args=[--] [<program>] [on|off|toggle]
|
||||
# omarchy:examples=omarchy crash mute | omarchy crash mute hyprland | omarchy crash mute /usr/bin/hyprland | omarchy crash mute hyprland off
|
||||
|
||||
# The flag omarchy-crash-watch reads before announcing a crash. Muting is per
|
||||
# program; Trigger > Toggle > Crash Capture is the switch for all of them.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
readonly MUTES="$HOME/.local/state/omarchy/toggles/crash-ignore"
|
||||
|
||||
usage() {
|
||||
echo "Usage: omarchy crash mute [--] [<program>] [on|off|toggle]" >&2
|
||||
}
|
||||
|
||||
# Only regular files, because that is all the watcher honours: anything else in
|
||||
# there would be reported as muted while the crashes kept arriving. The dotted
|
||||
# glob is for a program legitimately called .hidden, and `.` and `..` fail the
|
||||
# same -f test that keeps them out.
|
||||
list() {
|
||||
local entry found=0
|
||||
|
||||
for entry in "$MUTES"/* "$MUTES"/.*; do
|
||||
[[ -f $entry ]] || continue
|
||||
printf '%s\n' "${entry##*/}"
|
||||
found=1
|
||||
done
|
||||
|
||||
((found)) || echo "No programs muted. Crashes all notify."
|
||||
}
|
||||
|
||||
# A program may be named -h, and the router answers that with its own help
|
||||
# before this ever runs. `omarchy crash mute -- -h` is the way through.
|
||||
[[ ${1:-} == "--" ]] && shift
|
||||
|
||||
if (($# == 0)); then
|
||||
list
|
||||
exit 0
|
||||
fi
|
||||
|
||||
program=$1
|
||||
action=${2:-on}
|
||||
|
||||
# The watcher keys the mute on the executable's basename, so accept the path it
|
||||
# reports as readily as the name, and reduce either the same way it does.
|
||||
program=${program##*/}
|
||||
|
||||
if [[ -z $program || $program == "." || $program == ".." ]]; then
|
||||
echo "Not a program name: $1" >&2
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$action" in
|
||||
on|off|toggle) ;;
|
||||
*)
|
||||
echo "Not an action: $action" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
omarchy-toggle "crash-ignore/$program" "$action" || exit 1
|
||||
|
||||
# Report what is now true rather than what was asked for: the flag is what the
|
||||
# watcher reads, and a toggle does not say which way it went.
|
||||
if omarchy-toggle-enabled "crash-ignore/$program"; then
|
||||
echo "Muted crash notifications for $program."
|
||||
else
|
||||
echo "Crash notifications for $program are back on."
|
||||
fi
|
||||
+5
-28
@@ -48,17 +48,12 @@ announce() {
|
||||
# -n 0 so a restart does not re-announce crashes already dealt with.
|
||||
journalctl -f -n 0 -o json "MESSAGE_ID=$COREDUMP_MESSAGE_ID" 2>/dev/null |
|
||||
while IFS= read -r entry; do
|
||||
# A dash for a field that is empty as well as one that is missing: tab is
|
||||
# IFS whitespace, so an empty field collapses into the next delimiter and
|
||||
# every field after it shifts along one. A process can set its own comm to
|
||||
# nothing, and that crash used to be read as somebody else's and dropped.
|
||||
IFS=$'\t' read -r uid comm pid exe signal < <(
|
||||
jq -r 'def field: if . == null or . == "" then "-" else . end;
|
||||
[(._UID | field),
|
||||
(.COREDUMP_COMM | field),
|
||||
(.COREDUMP_PID | field),
|
||||
(.COREDUMP_EXE | field),
|
||||
(.COREDUMP_SIGNAL_NAME | field)] | @tsv' <<<"$entry" 2>/dev/null
|
||||
jq -r '[(._UID // "-"),
|
||||
(.COREDUMP_COMM // "-"),
|
||||
(.COREDUMP_PID // "-"),
|
||||
(.COREDUMP_EXE // "-"),
|
||||
(.COREDUMP_SIGNAL_NAME // "-")] | @tsv' <<<"$entry" 2>/dev/null
|
||||
)
|
||||
|
||||
[[ $pid =~ ^[0-9]+$ ]] || continue
|
||||
@@ -76,29 +71,11 @@ journalctl -f -n 0 -o json "MESSAGE_ID=$COREDUMP_MESSAGE_ID" 2>/dev/null |
|
||||
name=$comm
|
||||
[[ $exe == /* ]] && name=${exe##*/}
|
||||
|
||||
# A process can set its own comm to anything prctl takes, slashes included,
|
||||
# and a crash with no recorded executable falls back to it. The mute below
|
||||
# turns this name into a path, so keep it one component: a crash must not
|
||||
# reach a flag outside crash-ignore/, nor have a diagnosis write one there.
|
||||
name=${name##*/}
|
||||
|
||||
# What that leaves is not always a name. "/" leaves nothing, which is no
|
||||
# kind of array subscript and no kind of toast; a dot component names a
|
||||
# directory rather than a flag, so a mute on it would touch that directory
|
||||
# and then never match; and a dash is what the read above puts there when
|
||||
# the crash recorded no name at all.
|
||||
[[ -n $name && $name != "-" && $name != "." && $name != ".." ]] || name=unknown
|
||||
|
||||
[[ -n $ignore_pattern && $name =~ $ignore_pattern ]] && continue
|
||||
|
||||
# Never announce our own machinery, or it notifies about itself.
|
||||
[[ $name == omarchy-crash-* || $name == omarchy-agent-* ]] && continue
|
||||
|
||||
# Muted at the end of a diagnosis, when the user was offered it and said
|
||||
# yes. A flag per program rather than one list, so omarchy-crash-mute can
|
||||
# lift one without reading, rewriting and re-parsing the rest.
|
||||
omarchy-toggle-enabled "crash-ignore/$name" && continue
|
||||
|
||||
now=$EPOCHSECONDS
|
||||
(((now - ${last_notified[$name]:-0}) < dedupe_seconds)) && continue
|
||||
|
||||
|
||||
+13
-21
@@ -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|claude|codex|grok|gemini|openclaw|hermes|copilot|crush|cursor-agent|muse]
|
||||
# omarchy:examples=omarchy default agent | omarchy default agent codex | omarchy default agent claude
|
||||
|
||||
installing=false
|
||||
@@ -27,13 +27,12 @@ case "$1" in
|
||||
pi) agent="pi"; name="Pi" ;;
|
||||
omp | oh-my-pi) agent="omp"; name="Oh My Pi"; agent_package="github:can1357/oh-my-pi" ;;
|
||||
opencode | open-code) agent="opencode"; name="OpenCode" ;;
|
||||
ori | openrouter) agent="ori"; name="Ori"; agent_package="github:OpenRouterLabs/ori-releases" ;;
|
||||
claude | claude-code) agent="claude"; name="Claude Code" ;;
|
||||
codex) agent="codex"; name="Codex" ;;
|
||||
crush) agent="crush"; name="Crush" ;;
|
||||
grok) agent="grok"; name="Grok" ;;
|
||||
grok) agent="grok"; name="Grok"; agent_package="npm:@xai-official/grok" ;;
|
||||
gemini | gemini-cli) agent="gemini"; name="Gemini" ;;
|
||||
openclaw) agent="openclaw"; name="OpenClaw"; agent_installer="omarchy-install-openclaw-cli" ;;
|
||||
agy | antigravity | antigravity-cli | gemini | gemini-cli) agent="agy"; name="Antigravity"; agent_package="antigravity-cli" ;;
|
||||
hermes) agent="hermes"; name="Hermes"; agent_installer="omarchy-install-hermes-cli" ;;
|
||||
copilot | github-copilot) agent="copilot"; name="GitHub Copilot" ;;
|
||||
muse | muse-code | musecode)
|
||||
@@ -43,23 +42,22 @@ muse | muse-code | musecode)
|
||||
;;
|
||||
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|claude|codex|grok|gemini|openclaw|hermes|copilot|crush|cursor-agent|muse>"
|
||||
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.
|
||||
# Hermes reaches mise through its own installer rather than straight from
|
||||
# here: it needs its interpreter pinned, and a bare `mise use` has nowhere to
|
||||
# say so. See omarchy-install-hermes-cli. OpenClaw comes from its pacman
|
||||
# package the same way; see 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.
|
||||
# Not omarchy-cmd-present: the stub is on PATH from first boot and says
|
||||
# nothing about whether Hermes is installed behind it. Treating a cold stub
|
||||
# as installed skips the floating terminal and runs the minute-long install
|
||||
# inside the menu action instead.
|
||||
agent_present() { "$agent_installer" --check; }
|
||||
agent_install() { "$agent_installer" --now; }
|
||||
else
|
||||
@@ -79,9 +77,7 @@ if [[ $installing == "false" ]] && ! agent_present; then
|
||||
fi
|
||||
|
||||
if ! agent_install; then
|
||||
if [[ $installing == "true" && -n ${agent_installer:-} ]]; then
|
||||
echo "Could not install $name" >&2
|
||||
elif [[ $installing == "true" ]]; 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 +85,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"
|
||||
|
||||
|
||||
@@ -4,12 +4,6 @@
|
||||
# omarchy:args=[chromium|chrome|brave|brave-origin|edge|firefox|zen]
|
||||
# omarchy:examples=omarchy default browser firefox | omarchy default browser brave
|
||||
|
||||
installing=false
|
||||
if [[ ${1:-} == "--install" ]]; then
|
||||
installing=true
|
||||
shift
|
||||
fi
|
||||
|
||||
if (($# == 0)); then
|
||||
case "$(env -u BROWSER xdg-settings get default-web-browser)" in
|
||||
chromium.desktop) echo "chromium" ;;
|
||||
@@ -25,27 +19,19 @@ if (($# == 0)); then
|
||||
fi
|
||||
|
||||
case "$1" in
|
||||
chromium) browser="chromium"; command="chromium"; desktop_id="chromium.desktop"; name="Chromium"; glyph= ;;
|
||||
chrome) browser="chrome"; command="google-chrome-stable"; desktop_id="google-chrome.desktop"; name="Chrome"; glyph= ;;
|
||||
brave) browser="brave"; command="brave"; desktop_id="brave-browser.desktop"; name="Brave"; glyph= ;;
|
||||
brave-origin) browser="brave-origin"; command="brave-origin"; desktop_id="brave-origin.desktop"; name="Brave Origin"; glyph= ;;
|
||||
edge) browser="edge"; command="microsoft-edge-stable"; desktop_id="microsoft-edge.desktop"; name="Edge"; glyph= ;;
|
||||
firefox) browser="firefox"; command="firefox"; desktop_id="firefox.desktop"; name="Firefox"; glyph= ;;
|
||||
zen) browser="zen"; command="zen-browser"; desktop_id="zen.desktop"; name="Zen"; glyph= ;;
|
||||
chromium) desktop_id="chromium.desktop"; name="Chromium"; glyph= ;;
|
||||
chrome) desktop_id="google-chrome.desktop"; name="Chrome"; glyph= ;;
|
||||
brave) desktop_id="brave-browser.desktop"; name="Brave"; glyph= ;;
|
||||
brave-origin) desktop_id="brave-origin.desktop"; name="Brave Origin"; glyph= ;;
|
||||
edge) desktop_id="microsoft-edge.desktop"; name="Edge"; glyph= ;;
|
||||
firefox) desktop_id="firefox.desktop"; name="Firefox"; glyph= ;;
|
||||
zen) desktop_id="zen.desktop"; name="Zen"; glyph= ;;
|
||||
*)
|
||||
echo "Usage: omarchy-default-browser <chromium|chrome|brave|brave-origin|edge|firefox|zen>"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if omarchy-cmd-missing "$command"; then
|
||||
if [[ $installing == "false" ]]; then
|
||||
exec omarchy-launch-floating-terminal-with-presentation omarchy-default-browser --install "$browser"
|
||||
else
|
||||
omarchy-install-browser "$browser" || exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
env -u BROWSER xdg-settings set default-web-browser "$desktop_id" || exit 1
|
||||
|
||||
omarchy-notification-send -g $glyph "$name is now the default browser"
|
||||
@@ -4,12 +4,6 @@
|
||||
# omarchy:args=[code|cursor|zed|sublime_text|helix|vim|emacs|nvim]
|
||||
# omarchy:examples=omarchy default editor | omarchy default editor code | omarchy default editor helix
|
||||
|
||||
installing=false
|
||||
if [[ ${1:-} == "--install" ]]; then
|
||||
installing=true
|
||||
shift
|
||||
fi
|
||||
|
||||
editor_file="$HOME/.local/state/omarchy/defaults/editor"
|
||||
|
||||
if (($# == 0)); then
|
||||
@@ -17,45 +11,25 @@ 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
|
||||
|
||||
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= ;;
|
||||
helix) selection="helix"; editor="helix"; name="Helix"; glyph= ;;
|
||||
vim) selection="vim"; editor="vim"; name="Vim"; glyph= ;;
|
||||
emacs) selection="emacs"; editor="emacs"; name="Emacs"; glyph= ;;
|
||||
nvim) selection="nvim"; editor="nvim"; name="Neovim"; glyph= ;;
|
||||
code) editor="code"; name="VSCode"; glyph= ;;
|
||||
cursor) editor="cursor"; name="Cursor"; glyph= ;;
|
||||
zed | zeditor) editor="zeditor"; name="Zed"; glyph= ;;
|
||||
sublime_text) editor="sublime_text"; name="Sublime Text"; glyph= ;;
|
||||
helix) editor="helix"; name="Helix"; glyph= ;;
|
||||
vim) editor="vim"; name="Vim"; glyph= ;;
|
||||
emacs) editor="emacs"; name="Emacs"; glyph= ;;
|
||||
nvim) editor="nvim"; name="Neovim"; glyph= ;;
|
||||
*)
|
||||
echo "Usage: omarchy-default-editor <code|cursor|zed|sublime_text|helix|vim|emacs|nvim>"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if omarchy-cmd-missing "$editor"; then
|
||||
if [[ $installing == "false" ]]; then
|
||||
exec omarchy-launch-floating-terminal-with-presentation omarchy-default-editor --install "$selection"
|
||||
else
|
||||
case "$selection" in
|
||||
code) omarchy-install-editor-vscode ;;
|
||||
cursor) omarchy-pkg-add cursor-bin ;;
|
||||
zed) omarchy-install-editor-zed ;;
|
||||
sublime_text) omarchy-pkg-add sublime-text-4 ;;
|
||||
helix) omarchy-install-editor-helix ;;
|
||||
vim) omarchy-pkg-add vim ;;
|
||||
emacs) omarchy-install-editor-emacs ;;
|
||||
nvim) omarchy-pkg-add neovim ;;
|
||||
esac || exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$editor_file")"
|
||||
printf '%s\n' "$editor" >"$editor_file"
|
||||
|
||||
|
||||
@@ -4,12 +4,6 @@
|
||||
# omarchy:args=[alacritty|foot|ghostty|kitty]
|
||||
# omarchy:examples=omarchy default terminal ghostty | omarchy default terminal kitty
|
||||
|
||||
installing=false
|
||||
if [[ ${1:-} == "--install" ]]; then
|
||||
installing=true
|
||||
shift
|
||||
fi
|
||||
|
||||
if (($# == 0)); then
|
||||
desktop_id=$(xdg-terminal-exec --print-id 2>/dev/null || true)
|
||||
desktop_id=${desktop_id%%:*}
|
||||
@@ -24,24 +18,16 @@ if (($# == 0)); then
|
||||
fi
|
||||
|
||||
case "$1" in
|
||||
alacritty) terminal="alacritty"; desktop_id="Alacritty.desktop"; name="Alacritty"; glyph= ;;
|
||||
foot) terminal="foot"; desktop_id="foot.desktop"; name="Foot"; glyph= ;;
|
||||
ghostty) terminal="ghostty"; desktop_id="com.mitchellh.ghostty.desktop"; name="Ghostty"; glyph= ;;
|
||||
kitty) terminal="kitty"; desktop_id="kitty.desktop"; name="Kitty"; glyph= ;;
|
||||
alacritty) desktop_id="Alacritty.desktop"; name="Alacritty"; glyph= ;;
|
||||
foot) desktop_id="foot.desktop"; name="Foot"; glyph= ;;
|
||||
ghostty) desktop_id="com.mitchellh.ghostty.desktop"; name="Ghostty"; glyph= ;;
|
||||
kitty) desktop_id="kitty.desktop"; name="Kitty"; glyph= ;;
|
||||
*)
|
||||
echo "Usage: omarchy-default-terminal <alacritty|foot|ghostty|kitty>"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if omarchy-cmd-missing "$terminal"; then
|
||||
if [[ $installing == "false" ]]; then
|
||||
exec omarchy-launch-floating-terminal-with-presentation omarchy-default-terminal --install "$terminal"
|
||||
else
|
||||
omarchy-install-terminal "$terminal" || exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
cat >~/.config/xdg-terminals.list <<EOF
|
||||
# Terminal emulator preference order for xdg-terminal-exec
|
||||
# The first found and valid terminal will be used
|
||||
|
||||
@@ -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=Match the Dell XPS 13 DX13260 with an Intel Panther Lake GPU.
|
||||
|
||||
omarchy-hw-match "DX13260" && omarchy-hw-intel-ptl
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Match the Dell XPS 13 DX13260 that requires the sidecar amplifier workaround.
|
||||
|
||||
product_sku="${OMARCHY_DMI_PRODUCT_SKU:-/sys/class/dmi/id/product_sku}"
|
||||
|
||||
omarchy-hw-match "DX13260" &&
|
||||
grep -qix "0E53" "$product_sku" 2>/dev/null
|
||||
@@ -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."
|
||||
@@ -10,11 +10,150 @@ if (( EUID == 0 )); then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The same install the default agent gets, plus opening the window.
|
||||
omarchy-install-hermes-cli --now
|
||||
echo "Installing Hermes Desktop..."
|
||||
omarchy-pkg-add hermes-desktop
|
||||
|
||||
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
|
||||
|
||||
# If Hermes was already installed for the terminal, the app supersedes it: one
|
||||
# machine, one Hermes. This drops that copy so the terminal, the default agent
|
||||
# and the app all end up on the app's installation.
|
||||
omarchy-install-hermes-cli || true
|
||||
|
||||
# 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"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
check_main() {
|
||||
local main_commit
|
||||
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
|
||||
echo "Hermes main has local commits. Keep that work and prepare the desktop with 'hermes desktop --build-only'." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
if ! runtime_ready; 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.
|
||||
if [[ -e $runtime || -L $runtime ]]; then
|
||||
if [[ $(git -C "$runtime" rev-parse HEAD 2>/dev/null) != "$release_commit" ]] ||
|
||||
[[ -n $(git -C "$runtime" status --porcelain --untracked-files=all) ]]; then
|
||||
echo "Hermes setup is incomplete at $runtime. Repair that installation before trying again; existing files have been kept." >&2
|
||||
exit 1
|
||||
fi
|
||||
check_main
|
||||
fi
|
||||
|
||||
# Upstream replaces these commands, including foreign files and symlinks.
|
||||
# Keep their original bytes/links before handing the names to the desktop.
|
||||
command_backup=""
|
||||
for command in hermes hermes-agent hermes-acp; do
|
||||
command_path="$HOME/.local/bin/$command"
|
||||
if [[ -e $command_path || -L $command_path ]]; then
|
||||
if [[ ! -f $command_path && ! -L $command_path ]]; then
|
||||
echo "Cannot replace $command_path: move it aside before installing Hermes Desktop." >&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 -- "$command_path" "$command_backup/"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Setting up the Hermes runtime..."
|
||||
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
|
||||
fi
|
||||
|
||||
runtime_commit=$(git -C "$runtime" rev-parse HEAD)
|
||||
if [[ $runtime_commit == "$release_commit" ]]; then
|
||||
# The updater switches to main before checking for changes. Start main at
|
||||
# the packaged release, with enough history for its first fast-forward.
|
||||
check_main
|
||||
if [[ $(git -C "$runtime" rev-parse --is-shallow-repository) == "true" ]]; then
|
||||
git -C "$runtime" fetch --unshallow origin main
|
||||
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
|
||||
|
||||
if [[ -e $native_app || -L $native_app ]]; then
|
||||
if [[ ! -f $native_app/Hermes || ! -x $native_app/Hermes || ! -f $native_app/resources/app.asar || ! -f $native_app/resources/install-stamp.json ]]; then
|
||||
echo "The Hermes desktop app at $native_app is incomplete. Repair it with 'hermes desktop --build-only' before trying again." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
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
|
||||
exit 1
|
||||
fi
|
||||
desktop_changes=$(git -C "$runtime" status --porcelain --untracked-files=all -- apps/desktop package.json package-lock.json)
|
||||
if [[ -n $desktop_changes ]]; then
|
||||
echo "Hermes desktop sources have local changes. Run 'hermes desktop --build-only', then try again; existing files have been kept." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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])
|
||||
from hermes_cli.main import _write_desktop_build_stamp
|
||||
|
||||
_write_desktop_build_stamp(Path(sys.argv[1]), source_mode=False)
|
||||
PY
|
||||
fi
|
||||
|
||||
echo "Opening Hermes Desktop..."
|
||||
setsid uwsm-app -- /usr/bin/hermes-desktop >/dev/null 2>&1 &
|
||||
|
||||
# Only a running Hermes can be told which skin to show; a unit outlives this
|
||||
# terminal and reports to the journal.
|
||||
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
|
||||
|
||||
echo ""
|
||||
echo "Hermes Desktop has been installed."
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
set -e
|
||||
|
||||
# The same self-updating install the default agent gets.
|
||||
omarchy-install-openclaw-cli --now
|
||||
echo "Installing OpenClaw..."
|
||||
omarchy-pkg-add openclaw
|
||||
|
||||
# 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
|
||||
@@ -14,7 +14,7 @@ omarchy-install-openclaw-cli --now
|
||||
# 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 \
|
||||
/usr/lib/node_modules/openclaw/dist/control-ui/apple-touch-icon.png \
|
||||
omarchy-launch-openclaw
|
||||
|
||||
# A first install runs onboarding right here: launching the app instead would
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Install a supported browser
|
||||
# omarchy:args=<chromium|chrome|brave|brave-origin|edge|firefox|zen>
|
||||
# omarchy:examples=omarchy install browser chromium | omarchy install browser firefox
|
||||
|
||||
set -e
|
||||
# omarchy:args=<chrome|brave|brave-origin|edge|firefox|zen>
|
||||
# omarchy:examples=omarchy install browser firefox | omarchy install browser brave
|
||||
|
||||
source "$OMARCHY_PATH/install/helpers/browser-policy.sh"
|
||||
|
||||
@@ -30,15 +28,6 @@ setup_firefox_wayland() {
|
||||
}
|
||||
|
||||
case $1 in
|
||||
chromium)
|
||||
echo "Installing Chromium..."
|
||||
omarchy-pkg-add chromium
|
||||
|
||||
setup_chromium_policy_directory /etc/chromium/policies/managed
|
||||
copy_chromium_flags ~/.config/chromium-flags.conf
|
||||
omarchy-theme-set-browser
|
||||
announce_browser_installed "Chromium"
|
||||
;;
|
||||
chrome)
|
||||
echo "Installing Chrome..."
|
||||
omarchy-pkg-aur-add google-chrome || exit 1
|
||||
@@ -92,7 +81,7 @@ zen)
|
||||
announce_browser_installed "Zen"
|
||||
;;
|
||||
*)
|
||||
echo "Usage: omarchy-install-browser <chromium|chrome|brave|brave-origin|edge|firefox|zen>"
|
||||
echo "Usage: omarchy-install-browser <chrome|brave|brave-origin|edge|firefox|zen>"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -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..."
|
||||
|
||||
+222
-486
@@ -1,544 +1,280 @@
|
||||
#!/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
|
||||
# omarchy:summary=Install the Hermes CLI as a mise-backed wrapper in ~/.local/bin
|
||||
# omarchy:args=[--check|--now|--owns|--remove]
|
||||
# omarchy:examples=omarchy install hermes cli | omarchy install hermes cli --now
|
||||
|
||||
# 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.
|
||||
# Hermes pins every one of its dependencies exactly and declares
|
||||
# Requires-Python >=3.11,<3.14, so it can neither be built against Arch's
|
||||
# Python nor share the python-* packages. mise builds it a private environment
|
||||
# instead.
|
||||
#
|
||||
# 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.
|
||||
# It gets its own installer rather than a line in omarchy-mise-install because
|
||||
# of the interpreter pin. Given no compatible interpreter to hand, uv builds
|
||||
# the venv against the system Python in violation of Hermes' own bound,
|
||||
# reports success, and leaves the breakage to surface later inside a
|
||||
# dependency -- and omarchy-mise-install writes a fixed stub with nowhere to
|
||||
# say otherwise.
|
||||
#
|
||||
# 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.
|
||||
# There is only ever one Hermes on a machine. hermes-desktop cannot run against
|
||||
# this one -- it needs a runtime built from its own commit, and the version gap
|
||||
# fails its readiness probe -- so it installs its own under ~/.hermes and puts
|
||||
# that on PATH. When the package is present it therefore owns Hermes outright:
|
||||
# this installer stands aside and removes its own copy, so the terminal, the
|
||||
# default agent and the app are all the same installation.
|
||||
|
||||
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
|
||||
tool='pipx:hermes-agent[extras=all]'
|
||||
python='3.13'
|
||||
|
||||
runtime="$HERMES_HOME/hermes-agent"
|
||||
native_app="$runtime/apps/desktop/release/linux-unpacked"
|
||||
command_path="$HOME/.local/bin/hermes"
|
||||
# The line that identifies the stub as this installer's; matched whole, so a
|
||||
# wrapper that merely mentions the command is not mistaken for ours.
|
||||
marker='# Written by omarchy-install-hermes-cli.'
|
||||
|
||||
# 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"
|
||||
# The package, not the runtime directory: it is installed before the app has
|
||||
# ever run, and that is exactly when we must not start building a second copy.
|
||||
desktop_owns_hermes() {
|
||||
omarchy-pkg-present hermes-desktop
|
||||
}
|
||||
|
||||
# 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"
|
||||
# The venv appears at the python-deps stage, several stages before the one that
|
||||
# installs the command, so its presence says nothing about being usable. The
|
||||
# marker is written last, and the command is what the agent actually runs.
|
||||
desktop_hermes_ready() {
|
||||
[[ -f $HOME/.hermes/hermes-agent/.hermes-bootstrap-complete ]] || return 1
|
||||
|
||||
# An executable of that name proves nothing about whose it is; the app's own
|
||||
# points into ~/.hermes, and anything else is not the install we are asking
|
||||
# about. Matched as a plain string, because the path carries a dot and an
|
||||
# unanchored pattern would also claim a wrapper pointing at ~/xhermes.
|
||||
[[ -f $HOME/.local/bin/hermes ]] || return 1
|
||||
grep -qF "$HOME/.hermes" "$HOME/.local/bin/hermes" || return 1
|
||||
|
||||
# And a marker left behind by an install whose venv has since gone answers
|
||||
# for nothing, so the command has to run, exactly as a foreign one must.
|
||||
hermes_prompt_ready
|
||||
}
|
||||
|
||||
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
|
||||
# Whether Hermes is really installed, not merely whether the stub exists. A
|
||||
# stub on its own is cold: running it installs Hermes, which takes minutes.
|
||||
installed() {
|
||||
[[ -d "$(mise where "$tool" 2>/dev/null)/hermes-agent/lib/python$python" ]]
|
||||
}
|
||||
|
||||
# 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"
|
||||
# The stub is the only thing this installer owns. Anything else at that path
|
||||
# -- Hermes' official installer, a hand-rolled wrapper, even a dangling link
|
||||
# -- was put there by the user and is never deleted or overwritten here.
|
||||
# Symlinks count as foreign even when they resolve to a marked file: the stub
|
||||
# is written as a regular file, so a link is someone else's arrangement.
|
||||
ours() {
|
||||
[[ -f $HOME/.local/bin/hermes && ! -L $HOME/.local/bin/hermes ]] &&
|
||||
grep -qxF "$marker" "$HOME/.local/bin/hermes"
|
||||
}
|
||||
|
||||
# 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
|
||||
foreign_hermes() {
|
||||
[[ -e $HOME/.local/bin/hermes || -L $HOME/.local/bin/hermes ]] && ! ours
|
||||
}
|
||||
|
||||
# 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.
|
||||
# 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
|
||||
[[ -f $HOME/.local/bin/hermes && -x $HOME/.local/bin/hermes ]] &&
|
||||
timeout 15 "$HOME/.local/bin/hermes" --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.
|
||||
# what follows must be a shape argparse prints after a definition -- the usage
|
||||
# line's closing bracket, the gap before same-line help text, an uppercase
|
||||
# metavar, or the end of the line. Prose like "With --tui: run ..." stays
|
||||
# prose, and --tui-theme or --tui_mode never answers for --tui. Not probed 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.
|
||||
# for them: the old probe keyed on chat carrying --oneshot, which no released
|
||||
# Hermes did (it lived at the top level until v0.21 added chat's own), so
|
||||
# every release read as "not ready".
|
||||
hermes_prompt_ready() {
|
||||
local help
|
||||
hermes_runs &&
|
||||
help=$(timeout 15 "$command_path" chat --help 2>/dev/null) &&
|
||||
help=$(timeout 15 "$HOME/.local/bin/hermes" 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
|
||||
# --owns answers whether the wrapper on PATH is the one this command wrote, so
|
||||
# the migration and Remove Preinstalls do not each carry their own copy of the
|
||||
# marker and drift from it.
|
||||
if [[ $mode == "--owns" ]]; then
|
||||
if ours; then exit 0; else exit 1; fi
|
||||
fi
|
||||
|
||||
if installed; then
|
||||
# --remove tears down a Hermes CLI this installer put in place -- the mise tool
|
||||
# it installed and the stub it marked -- so Remove Hermes clears a CLI the app
|
||||
# never superseded (an interrupted install, or the terminal CLI from before the
|
||||
# app existed) rather than leaving it stranded on PATH. The whole teardown
|
||||
# turns on the marked stub, exactly as replacement does further down: without
|
||||
# it nothing proves the mise environment is Omarchy's rather than one the user
|
||||
# built against the same spec, and a user's stays theirs. The desktop takeover
|
||||
# removes the environment without asking, but that is its own bargain -- a
|
||||
# second Hermes has to go whoever built it, and the app still provides the
|
||||
# command afterwards; here nothing would. Idempotent: nothing owned, nothing
|
||||
# to do.
|
||||
if [[ $mode == "--remove" ]]; then
|
||||
if ours; then
|
||||
mise rm -g "$tool" >/dev/null 2>&1 || true
|
||||
mise uninstall --all "$tool" >/dev/null 2>&1 || true
|
||||
rm -f "$HOME/.local/bin/hermes" 2>/dev/null || true
|
||||
|
||||
# Every step is attempted before any is judged, and judged by what is left
|
||||
# rather than by what the commands claimed: the marked stub still answering
|
||||
# hermes, or mise still resolving the tool, is a CLI still installed no
|
||||
# matter how the removal exited.
|
||||
# Not "run --remove again": once the stub is gone nothing marks the mise
|
||||
# environment as ours, so a rerun would find nothing it owns and succeed
|
||||
# without touching what was left. Only the full commands finish the job.
|
||||
if ours || mise where "$tool" >/dev/null 2>&1; then
|
||||
echo "Could not remove the Hermes CLI Omarchy installed. Finish by hand:" >&2
|
||||
echo " rm -f ~/.local/bin/hermes" >&2
|
||||
echo " mise rm -g '$tool'" >&2
|
||||
echo " mise uninstall --all '$tool'" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! omarchy-pkg-present hermes-desktop; then
|
||||
echo "Installing Hermes Desktop..."
|
||||
omarchy-pkg-add hermes-desktop
|
||||
# --check lets callers tell a cold stub from a working one before they commit
|
||||
# to a path that assumes Hermes is ready.
|
||||
if [[ $mode == "--check" ]]; then
|
||||
if desktop_owns_hermes; then
|
||||
if desktop_hermes_ready; then exit 0; else exit 1; fi
|
||||
fi
|
||||
# A foreign command is ready only when it also supports prompted sessions;
|
||||
# since it is not ours to replace, nothing this installer does will update it.
|
||||
if foreign_hermes; then
|
||||
if hermes_prompt_ready; then exit 0; else exit 1; fi
|
||||
fi
|
||||
if installed && hermes_prompt_ready; then exit 0; else exit 1; fi
|
||||
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
|
||||
# Hand Hermes over to the app rather than keeping a second copy beside it.
|
||||
if desktop_owns_hermes; then
|
||||
# Not gated on that copy being healthy: `mise up` can rebuild it against the
|
||||
# wrong interpreter and a half-finished install answers to neither test, and
|
||||
# either way it is still a second Hermes. Removing nothing is harmless.
|
||||
if mise where "$tool" >/dev/null 2>&1; then
|
||||
echo "Hermes Desktop provides Hermes; removing the separate CLI install..." >&2
|
||||
fi
|
||||
|
||||
mise rm -g "$tool" >/dev/null 2>&1 || true
|
||||
mise uninstall --all "$tool" >/dev/null 2>&1 || true
|
||||
|
||||
# Our own stub has to go with it. Left in place it still answers `hermes`
|
||||
# until the app's bootstrap overwrites it, and answering means building the
|
||||
# second Hermes this whole arrangement exists to avoid.
|
||||
if ours; then
|
||||
rm -f "$HOME/.local/bin/hermes"
|
||||
fi
|
||||
|
||||
if desktop_hermes_ready; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Hermes Desktop is installed but has not set Hermes up yet." >&2
|
||||
echo "Launch Hermes Desktop once to finish installing it." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
runtime_present=false
|
||||
if runtime_ready; then
|
||||
runtime_present=true
|
||||
fi
|
||||
# The user already has a hermes of their own. Leave it be: a working one is
|
||||
# what the default agent will run, and a broken one is theirs to fix.
|
||||
if foreign_hermes; then
|
||||
if hermes_prompt_ready; then
|
||||
exit 0
|
||||
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
|
||||
if hermes_runs; then
|
||||
echo "~/.local/bin/hermes does not support the interactive seeded sessions Omarchy needs." >&2
|
||||
echo "Update it to a Hermes Agent release with interactive chat queries, then run omarchy-install-hermes-cli again." >&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
|
||||
echo "~/.local/bin/hermes exists but is not runnable, and it was not installed by Omarchy." >&2
|
||||
echo "Fix or remove it, then run omarchy-install-hermes-cli 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
|
||||
# Only the marked wrapper proves the matching mise environment is ours to replace.
|
||||
if installed && ! hermes_prompt_ready; then
|
||||
if ours; then
|
||||
echo "Updating Hermes for prompted sessions..." >&2
|
||||
mise rm -g "$tool" >/dev/null 2>&1 || true
|
||||
mise uninstall --all "$tool" >/dev/null 2>&1 || true
|
||||
else
|
||||
echo "A Hermes mise environment exists without an Omarchy-owned wrapper." >&2
|
||||
echo "Update or remove it explicitly, then run omarchy-install-hermes-cli again." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
rm -f "$HOME/.local/bin/hermes"
|
||||
|
||||
cat >"$HOME/.local/bin/hermes" <<EOF
|
||||
#!/bin/bash
|
||||
|
||||
$marker
|
||||
|
||||
export UV_PYTHON="$python"
|
||||
|
||||
# Exported rather than set on the install line alone, so the version resolved
|
||||
# to run agrees with the one just installed. Hermes ships several times a week
|
||||
# and mise's cooldown would otherwise hold a new release back for days.
|
||||
export MISE_MINIMUM_RELEASE_AGE=0
|
||||
|
||||
# mise up -- which omarchy update runs -- reinstalls without that pin, so this
|
||||
# asks which interpreter is actually there rather than whether anything is.
|
||||
if ! [[ -d "\$(mise where '$tool' 2>/dev/null)/hermes-agent/lib/python$python" ]]; then
|
||||
echo "Installing Hermes on Python $python (this takes a minute)..." >&2
|
||||
|
||||
# mise's pipx backend shells out to uv, which a stock Omarchy does not have.
|
||||
# It is fetched here rather than when this stub was written, so setting up a
|
||||
# machine that never runs Hermes costs nothing.
|
||||
if omarchy-cmd-missing uv && ! mise where uv >/dev/null 2>&1; then
|
||||
mise use -g --quiet uv@latest || exit 1
|
||||
fi
|
||||
|
||||
mise use -g --quiet --force '$tool' || exit 1
|
||||
fi
|
||||
|
||||
# The pin belongs to building Hermes, not to everything Hermes then runs.
|
||||
# Exported it would reach the agent and every command it shells out to, so a
|
||||
# uv in the user's own project would resolve 3.13 there too -- uv only warns
|
||||
# when that contradicts the project's requires-python, and builds it anyway.
|
||||
exec env -u UV_PYTHON mise x '$tool' -- hermes "\$@"
|
||||
EOF
|
||||
|
||||
chmod +x "$HOME/.local/bin/hermes"
|
||||
|
||||
# The desktop app resolves a hermes on PATH by running `hermes --version` with
|
||||
# a 15 second budget, then falls back to cloning its own copy when that times
|
||||
# out. A first-run mise install does not fit in 15 seconds, so anything that
|
||||
# hands Hermes to the GUI has to install it here rather than leave it stubbed.
|
||||
if [[ $mode == "--now" ]]; then
|
||||
"$HOME/.local/bin/hermes" --version
|
||||
if ! hermes_prompt_ready; then
|
||||
echo "Hermes installed without the interactive seeded sessions Omarchy needs." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
@@ -1,271 +1,29 @@
|
||||
#!/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:summary=Ensure the OpenClaw CLI is installed for the default agent
|
||||
# omarchy:args=[--check|--now]
|
||||
# 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.
|
||||
# OpenClaw is not a mise tool: the openclaw pacman package is the one OpenClaw
|
||||
# installation on the machine — the CLI, the gateway, and the Install > AI web
|
||||
# app all share it, and the fast ring keeps it current. The default-agent flow
|
||||
# talks to that package through the same --check/--now contract mise-backed
|
||||
# agents get from mise itself.
|
||||
|
||||
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
|
||||
case "${1:---now}" in
|
||||
--check)
|
||||
if installed; then exit 0; else exit 1; fi
|
||||
omarchy-pkg-present openclaw
|
||||
;;
|
||||
--now)
|
||||
if ! omarchy-pkg-present openclaw; then
|
||||
echo "Installing OpenClaw..."
|
||||
omarchy-pkg-add openclaw
|
||||
fi
|
||||
;;
|
||||
--now) ;;
|
||||
*)
|
||||
echo "Usage: omarchy-install-openclaw-cli <--check|--now>" >&2
|
||||
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)"
|
||||
@@ -10,4 +10,4 @@ echo "Enabling ONCE background service..."
|
||||
sudo systemctl enable --now once-background.service
|
||||
|
||||
echo -e "\nLaunching ONCE..."
|
||||
sudo once
|
||||
once
|
||||
+13
-196
@@ -10,50 +10,10 @@
|
||||
|
||||
LOGO_FILE="$HOME/.config/omarchy/branding/about.txt"
|
||||
FIT_FILE="$HOME/.local/state/omarchy/windows/about.fit"
|
||||
OMARCHY_FASTFETCH_DIR=/etc/fastfetch
|
||||
|
||||
# The logo block in the fastfetch config. The fit below reproduces this layout to
|
||||
# size the window, and the sheen has to repaint the very cells fastfetch drew the
|
||||
# logo on, so both read the padding from here.
|
||||
LOGO_PAD_LEFT=2
|
||||
LOGO_PAD_TOP=2
|
||||
LOGO_PAD_RIGHT=6
|
||||
|
||||
# The content is measured once and then goes on living: an uptime that turns
|
||||
# minutes into hours and hours into days, a version string that grows, a module
|
||||
# that shows up on the next boot. A window fitted to exactly what was measured
|
||||
# has nowhere to put any of it, and the layout clips or scrolls the moment it
|
||||
# grows — which is also the moment the logo stops being where it was drawn. Keep
|
||||
# a little in hand rather than measure again every time something ticks over.
|
||||
FIT_SPARE_COLUMNS=2
|
||||
FIT_SPARE_ROWS=1
|
||||
|
||||
POLL_SECONDS=0.5
|
||||
|
||||
# fastfetch has no animation of its own, so the sheen is ours. It knows about a
|
||||
# logo and nothing about About, which is why it is a file of its own.
|
||||
source omarchy-branding-about-animation
|
||||
|
||||
# A user's own fastfetch config can relocate or restyle the logo in ways this
|
||||
# measurement cannot see, so leave sizing to the float rule in that case. It can
|
||||
# sit in any of several directories fastfetch searches ahead of Omarchy's own, so
|
||||
# ask fastfetch for that order rather than keep a copy here for its next release
|
||||
# to outdate.
|
||||
# A user-level fastfetch config can relocate or restyle the logo in ways this
|
||||
# measurement cannot see, so leave sizing to the float rule in that case.
|
||||
custom_fastfetch_config() {
|
||||
local directory listed=false
|
||||
|
||||
# A whole line at a time, because a home directory may contain a space, and the
|
||||
# marker fastfetch puts beside the config it settled on is not part of the path.
|
||||
while IFS= read -r directory; do
|
||||
listed=true
|
||||
directory=${directory% (\*)}
|
||||
[[ ${directory%/} == "$OMARCHY_FASTFETCH_DIR" ]] && return 1
|
||||
[[ -f ${directory%/}/config.jsonc ]] && return 0
|
||||
done < <(fastfetch --list-config-paths 2>/dev/null)
|
||||
|
||||
# Silence is not the same answer as "none of them", so fall back to the
|
||||
# directory fastfetch has always looked in first rather than read it as one.
|
||||
[[ $listed == true ]] && return 1
|
||||
[[ -f $HOME/.config/fastfetch/config.jsonc ]]
|
||||
}
|
||||
|
||||
@@ -140,22 +100,17 @@ fit_window() {
|
||||
|
||||
# The guard character keeps command substitution from eating the trailing
|
||||
# break line, which provides the bottom padding row.
|
||||
local modules module_w
|
||||
local modules module_w module_h
|
||||
modules=$(fastfetch --logo none | sed 's/\x1b\[[0-9;?]*[a-zA-Z]//g'; printf X)
|
||||
modules=${modules%X}
|
||||
module_w=$(printf '%s' "$modules" | display_columns)
|
||||
|
||||
# Ask fastfetch how tall its layout came out rather than predicting it from the
|
||||
# logo and the module column: once the logo is the taller of the two, fastfetch
|
||||
# writes a row more than that arithmetic expects, and a window sized by it
|
||||
# scrolls the top padding away.
|
||||
measure_layout || return 1
|
||||
module_h=$(printf '%s' "$modules" | wc -l)
|
||||
|
||||
# Mirror the logo block in the fastfetch config: 2 columns of padding left of
|
||||
# the logo, 6 between logo and modules. Then 2 columns of right padding to
|
||||
# match, a row for the cursor so the trailing break shows, and the spare above.
|
||||
local target_c=$(( LOGO_PAD_LEFT + logo_w + LOGO_PAD_RIGHT + module_w + LOGO_PAD_LEFT + FIT_SPARE_COLUMNS ))
|
||||
local target_r=$(( LAYOUT_ROWS + 1 + FIT_SPARE_ROWS ))
|
||||
# the logo, 6 between logo and modules, 2 rows above it. Then 2 columns of
|
||||
# right padding to match, and a row for the cursor so the trailing break shows.
|
||||
local target_c=$(( 2 + logo_w + 6 + module_w + 2 ))
|
||||
local target_r=$(( (logo_h + 2 > module_h ? logo_h + 2 : module_h) + 1 ))
|
||||
|
||||
local nudges=0 rows cols address width height shift_w shift_h target_w target_h
|
||||
while :; do
|
||||
@@ -191,155 +146,17 @@ fit_window() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# One run answers both questions the sheen has to ask first. How tall the layout
|
||||
# is, because a window too small for it scrolls, which moves the logo off the rows
|
||||
# the frames address. And what colour fastfetch drew the logo in, because the
|
||||
# glint has to hand every cell back in the colour it arrived in — assume it, and
|
||||
# a logo fastfetch colours differently comes out of the first glint a new one.
|
||||
measure_layout() {
|
||||
[[ -n ${LAYOUT_ROWS:-} ]] && return 0
|
||||
|
||||
local rendered plain needle offset found row
|
||||
|
||||
# --pipe false because fastfetch drops its colours when it is not writing to a
|
||||
# terminal, and it is writing to this substitution.
|
||||
rendered=$(fastfetch --pipe false 2>/dev/null; printf X)
|
||||
rendered=${rendered%X}
|
||||
LAYOUT_ROWS=$(printf '%s' "$rendered" | wc -l)
|
||||
(( LAYOUT_ROWS > 0 )) || return 1
|
||||
|
||||
# Find the logo in what fastfetch drew rather than working it out from the
|
||||
# padding this file was written against. The config that runs is the one in
|
||||
# /etc, which a checkout does not replace, so the two can disagree — and a logo
|
||||
# measured two rows above where it was drawn is a logo the sheen moves. Not
|
||||
# finding it at all is the same answer as finding it somewhere unexpected:
|
||||
# whatever is on screen is not the text in the file, so leave it alone.
|
||||
IFS=$'\t' read -r offset needle < <(logo_landmark) || return 1
|
||||
[[ -n $needle ]] || return 1
|
||||
|
||||
plain=$(printf '%s' "$rendered" | sed 's/\x1b\[[0-9;?]*[a-zA-Z]//g')
|
||||
found=$(printf '%s' "$plain" | LC_ALL=C.UTF-8 awk -v needle="$needle" \
|
||||
'index($0, needle) { print NR, index($0, needle); exit }')
|
||||
[[ -n $found ]] || return 1
|
||||
read -r LOGO_ROW LOGO_COLUMN <<<"$found"
|
||||
LOGO_ROW=$(( LOGO_ROW - offset ))
|
||||
(( LOGO_ROW >= 1 && LOGO_COLUMN >= 1 )) || return 1
|
||||
|
||||
# Whatever fastfetch set before that row is what the sheen has to give back.
|
||||
row=$(printf '%s' "$rendered" | sed -n "$(( LOGO_ROW + offset ))p")
|
||||
LOGO_COLOR=""
|
||||
[[ $row =~ ^(($ESC\[[0-9;]*m)+) ]] && LOGO_COLOR=${BASH_REMATCH[1]}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# The longest line of the logo, and how far down the logo it sits — the most
|
||||
# distinctive thing to look for in the render, and the offset that turns where it
|
||||
# was found back into where the logo starts.
|
||||
# Tab-separated, and the offset first, because the line may hold spaces of its
|
||||
# own and splitting on them would cut the landmark short.
|
||||
logo_landmark() {
|
||||
LC_ALL=C.UTF-8 awk '
|
||||
{ if (length($0) > best) { best = length($0); line = $0; at = NR - 1 } }
|
||||
END { if (best > 0) printf "%d\t%s\n", at, line }
|
||||
' "$LOGO_FILE" 2>/dev/null
|
||||
}
|
||||
|
||||
# The About screen's own reasons the logo might not be where these frames would
|
||||
# draw it. Whether the logo itself can be animated is the sheen's own question.
|
||||
build_sheen() {
|
||||
# Whatever the last build left is not this window's, and the loop below plays
|
||||
# whatever is here — so a build that fails has to leave nothing to play.
|
||||
SHEEN_FRAMES=()
|
||||
|
||||
custom_fastfetch_config && return 1
|
||||
|
||||
# fastfetch honours NO_COLOR when it writes to a terminal but not when it writes
|
||||
# to the measurement below, so a logo drawn without colour would be measured as
|
||||
# green and left green by the first glint. A glint is colour anyway, which is
|
||||
# the thing NO_COLOR asks for none of.
|
||||
[[ -n ${NO_COLOR:-} ]] && return 1
|
||||
|
||||
measure_layout || return 1
|
||||
|
||||
# The layout needs a row for the cursor past its last line. Without one it has
|
||||
# scrolled, and the logo is no longer on the rows the frames address. Ask the
|
||||
# terminal where the cursor actually is rather than trust the arithmetic, and
|
||||
# keep the arithmetic for a terminal that will not say.
|
||||
local rows cols
|
||||
read -r rows cols <<<"$(stty size)"
|
||||
(( rows > LAYOUT_ROWS )) || return 1
|
||||
|
||||
# The cell the logo's first row starts on, every attribute fastfetch left on
|
||||
# those cells so a glint that has passed leaves them as it found them, and the
|
||||
# room it has to work in left of the module column.
|
||||
sheen_build "$LOGO_FILE" "$LOGO_ROW" "$LOGO_COLUMN" "${ESC}[0m${LOGO_COLOR}" "$(( cols - LOGO_COLUMN + 1 ))"
|
||||
}
|
||||
|
||||
# What the frames were built against. A window that resized, or a logo that was
|
||||
# rebranded, needs fastfetch run again before anything is drawn over it.
|
||||
content_changed() {
|
||||
[[ $resized == true ]] && return 0
|
||||
|
||||
[[ $(stty size) != "$grid" || $(stat -c %Y "$LOGO_FILE" 2>/dev/null) != "$logo_stamp" ]]
|
||||
}
|
||||
|
||||
# A tick either times out, which is the delay, or a key arrives and About closes.
|
||||
# Anything else on stdin is a terminal that went away, which closes it too.
|
||||
tick() {
|
||||
read -t "$1" -n 1 -s && exit
|
||||
(( $? > 128 )) || exit
|
||||
}
|
||||
|
||||
play_sheen() {
|
||||
local index
|
||||
|
||||
for (( index = 0; index < ${#SHEEN_FRAMES[@]}; index++ )); do
|
||||
# Stop before painting a frame rather than after: a resize has already moved
|
||||
# the cells these address, and the rest of a sweep would land across them.
|
||||
# The grid costs a process, so it stays on the poll interval. The trap costs
|
||||
# nothing, so it is read last — a signal that arrived while the grid was being
|
||||
# read would otherwise be seen only after another frame had gone out.
|
||||
(( index % SHEEN_POLL_FRAMES == 0 )) && content_changed && return 1
|
||||
[[ $resized == true ]] && return 1
|
||||
|
||||
printf '%s' "${SHEEN_FRAMES[index]}"
|
||||
tick "$SHEEN_FRAME_SECONDS"
|
||||
done
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# The logo is still between glints, so About is a quiet window to leave open.
|
||||
rest_sheen() {
|
||||
local ticks
|
||||
|
||||
for (( ticks = 0; ticks < SHEEN_REST_TICKS; ticks++ )); do
|
||||
tick "$POLL_SECONDS"
|
||||
content_changed && return 1
|
||||
done
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
if [[ ${1:-} == "--render" ]]; then
|
||||
printf '\e[?25l'
|
||||
|
||||
# A sweep runs for seconds between polls, so it reads this instead. The polling
|
||||
# stays as the backstop, for a signal that arrived while it could not be taken.
|
||||
resized=false
|
||||
trap 'resized=true' WINCH
|
||||
|
||||
# Give the compositor a moment to apply the window rules before measuring cells.
|
||||
settle_grid
|
||||
|
||||
fitted=false
|
||||
passes=0
|
||||
while :; do
|
||||
grid=$(stty size)
|
||||
size=$(stty size)
|
||||
logo_stamp=$(stat -c %Y "$LOGO_FILE" 2>/dev/null)
|
||||
resized=false
|
||||
LAYOUT_ROWS=""
|
||||
clear
|
||||
fastfetch
|
||||
# A second pass picks up a fit that could not measure the window the first
|
||||
@@ -348,10 +165,10 @@ if [[ ${1:-} == "--render" ]]; then
|
||||
if [[ $fitted == false ]] && (( ++passes <= 2 )); then
|
||||
fit_window && fitted=true
|
||||
fi
|
||||
# An empty frame list plays nothing, so a logo that cannot be animated waits
|
||||
# here exactly as the still one did, and there is one loop rather than two.
|
||||
build_sheen
|
||||
while play_sheen && rest_sheen; do :; done
|
||||
while [[ $(stty size) == $size && $(stat -c %Y "$LOGO_FILE" 2>/dev/null) == $logo_stamp ]]; do
|
||||
read -t 0.5 -n 1 -s && exit
|
||||
(( $? > 128 )) || exit
|
||||
done
|
||||
# A rebranded logo changes the content dimensions, so measure again.
|
||||
if [[ $(stat -c %Y "$LOGO_FILE" 2>/dev/null) != $logo_stamp ]]; then
|
||||
fitted=false
|
||||
|
||||
@@ -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"
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
# omarchy:summary=Launch the Omarchy screensaver in the default terminal on the system with the correct font configuration.
|
||||
|
||||
if omarchy-cmd-missing ttfx; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Exit early if screensaver is already running
|
||||
pgrep -f '[o]rg.omarchy.screensaver' && exit 0
|
||||
|
||||
@@ -29,8 +33,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 +53,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 +72,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.
|
||||
|
||||
@@ -38,12 +38,6 @@ parse_keycodes() {
|
||||
if (code != "" && symbol != "" && symbol != "NoSymbol") keycode_symbol[code] = toupper(symbol)
|
||||
}
|
||||
|
||||
# Read off the keysym rather than the keycode, so the key left of 1 reads
|
||||
# as the symbol printed on it whichever key the active layout puts it on.
|
||||
for (code in keycode_symbol) {
|
||||
if (keycode_symbol[code] == "GRAVE") keycode_symbol[code] = "~"
|
||||
}
|
||||
|
||||
mouse_symbol["272"] = "LEFT MOUSE BUTTON"
|
||||
mouse_symbol["273"] = "RIGHT MOUSE BUTTON"
|
||||
mouse_symbol["274"] = "MIDDLE MOUSE BUTTON"
|
||||
@@ -210,9 +204,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"))
|
||||
@@ -314,7 +305,6 @@ dynamic_bindings() {
|
||||
|
||||
case "$key" in
|
||||
comma) key="COMMA" ;;
|
||||
grave) key="~" ;;
|
||||
period) key="PERIOD" ;;
|
||||
minus) key="MINUS" ;;
|
||||
equal) key="EQUAL" ;;
|
||||
@@ -334,41 +324,15 @@ static_bindings() {
|
||||
echo "SHIFT ALT,D,Download Video from Web App,sendshortcut,SHIFT ALT,D,"
|
||||
}
|
||||
|
||||
# Actions Omarchy binds to a second chord meant as an alternative, named one at
|
||||
# a time. A rule would be wrong here: Alt + Tab and Shift + Alt + Tab both say
|
||||
# "Reveal active window on top" while cycling opposite ways, and a media key is
|
||||
# nobody's idea of an alternative to a Super chord. Only chords a user reaches
|
||||
# for interchangeably belong on one row.
|
||||
alternative_chord_actions() {
|
||||
cat <<'ACTIONS'
|
||||
Close window
|
||||
Calculator
|
||||
Toggle scratchpad
|
||||
Move window to scratchpad
|
||||
ACTIONS
|
||||
}
|
||||
|
||||
# Parse and format keybindings
|
||||
#
|
||||
# `awk` does the heavy lifting:
|
||||
# - Set the field separator to a comma ','.
|
||||
# - Joins the key combination (e.g., "SUPER + Q").
|
||||
# - Joins the command that the key executes.
|
||||
# - Puts an action's alternative chord on the row with the first one.
|
||||
# - Prints display text and dispatch metadata as tab-separated fields.
|
||||
parse_binding_records() {
|
||||
awk -F, -v alternatives="$(alternative_chord_actions)" '
|
||||
BEGIN {
|
||||
# The column every row pads its chords to. Nothing is allowed past it: the
|
||||
# menu renders in monospace, and a row that overruns pushes its arrow out
|
||||
# of a column the eye reads straight down.
|
||||
column = 35;
|
||||
|
||||
split(alternatives, named, "\n");
|
||||
for (i in named) {
|
||||
if (named[i] != "") shares_a_row[named[i]] = 1;
|
||||
}
|
||||
}
|
||||
awk -F, '
|
||||
{
|
||||
# Combine the modifier and key (first two fields)
|
||||
key_combo = $1 " + " $2;
|
||||
@@ -401,35 +365,8 @@ BEGIN {
|
||||
gsub(/[ \t]+/, " ", key_combo); # Collapse multiple spaces to one
|
||||
}
|
||||
|
||||
# An alternative chord joins the row the first one opened: "SUPER + W /
|
||||
# SUPER + Q -> Close window". Both halves have to agree on what they run,
|
||||
# since a label is only what a chord is called; an unresolved dispatcher
|
||||
# says nothing at all, so it never counts as agreement.
|
||||
if (action != "") {
|
||||
action_key = action SUBSEP dispatcher SUBSEP arg;
|
||||
together = "";
|
||||
|
||||
if (action in shares_a_row && dispatcher != "" && action_key in leads) {
|
||||
together = chords[leads[action_key]] " / " key_combo;
|
||||
}
|
||||
|
||||
if (together != "" && length(together) <= column) {
|
||||
chords[leads[action_key]] = together;
|
||||
} else {
|
||||
# Also the path a pair too wide for the column takes: two rows in
|
||||
# line beat one that juts out of it.
|
||||
entries++;
|
||||
chords[entries] = key_combo;
|
||||
actions[entries] = action;
|
||||
dispatchers[entries] = dispatcher;
|
||||
args[entries] = arg;
|
||||
leads[action_key] = entries;
|
||||
}
|
||||
}
|
||||
}
|
||||
END {
|
||||
for (entry = 1; entry <= entries; entry++) {
|
||||
printf "%-*s → %s\t%s\t%s\n", column, chords[entry], actions[entry], dispatchers[entry], args[entry];
|
||||
printf "%-35s → %s\t%s\t%s\n", key_combo, action, dispatcher, arg;
|
||||
}
|
||||
}'
|
||||
}
|
||||
@@ -437,13 +374,7 @@ END {
|
||||
prioritize_entries() {
|
||||
awk -F '\t' '
|
||||
{
|
||||
# Alternative chords are display only. Classifying on them would read
|
||||
# "SUPER SHIFT + RETURN / SUPER SHIFT + B" as the alternate browser chord
|
||||
# and drop the browser 20 rows down its own list, so match the chord that
|
||||
# leads the row and ignore the rest.
|
||||
line = $1
|
||||
sub(/ \/ [^→]*→/, " →", line)
|
||||
|
||||
prio = 50
|
||||
if (match(line, /Keybindings/)) prio = 0
|
||||
if (match(line, /Omarchy menu/)) prio = 1
|
||||
@@ -505,12 +436,6 @@ prioritize_entries() {
|
||||
cut -f2-
|
||||
}
|
||||
|
||||
# Drop repeated records while keeping the order Hyprland reported them in, so
|
||||
# the chord a user declared first is the one that leads a merged entry.
|
||||
dedupe_binding_records() {
|
||||
awk '!seen[$0]++'
|
||||
}
|
||||
|
||||
output_binding_records_uncached() {
|
||||
local dynamic
|
||||
|
||||
@@ -521,7 +446,7 @@ output_binding_records_uncached() {
|
||||
[[ -n $dynamic ]] && printf '%s\n' "$dynamic"
|
||||
static_bindings
|
||||
} |
|
||||
dedupe_binding_records |
|
||||
sort -u |
|
||||
parse_keycodes |
|
||||
parse_binding_records |
|
||||
prioritize_entries
|
||||
@@ -533,7 +458,7 @@ output_binding_records_uncached() {
|
||||
|
||||
keybindings_cache_key() {
|
||||
{
|
||||
printf 'v14\n'
|
||||
printf 'v11\n'
|
||||
hyprctl devices 2>/dev/null | grep -F 'active keymap:'
|
||||
hyprctl binds 2>/dev/null
|
||||
} | sha256sum | awk '{ print $1 }'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,18 +34,23 @@ print_status() {
|
||||
return
|
||||
fi
|
||||
|
||||
nm=$(nmcli -t -f GENERAL.STATE,GENERAL.CONNECTION dev show "$device" 2>/dev/null)
|
||||
state=$(awk -F: '$1 == "GENERAL.STATE" { print $2; exit }' <<<"$nm")
|
||||
ssid=$(awk -F: '$1 == "GENERAL.CONNECTION" { print $2; exit }' <<<"$nm")
|
||||
signal=$(nmcli -t -f IN-USE,SIGNAL dev wifi list ifname "$device" --rescan no 2>/dev/null | awk -F: '$1 == "*" { print $2; exit }')
|
||||
freq=$(iw dev "$device" link 2>/dev/null | awk '/freq:/ { print $2; exit }')
|
||||
if omarchy-cmd-present nmcli; then
|
||||
nm=$(nmcli -t -f GENERAL.STATE,GENERAL.CONNECTION dev show "$device" 2>/dev/null)
|
||||
state=$(awk -F: '$1 == "GENERAL.STATE" { print $2; exit }' <<<"$nm")
|
||||
ssid=$(awk -F: '$1 == "GENERAL.CONNECTION" { print $2; exit }' <<<"$nm")
|
||||
signal=$(nmcli -t -f IN-USE,SIGNAL dev wifi list ifname "$device" --rescan no 2>/dev/null | awk -F: '$1 == "*" { print $2; exit }')
|
||||
freq=$(iw dev "$device" link 2>/dev/null | awk '/freq:/ { print $2; exit }')
|
||||
|
||||
if [[ $state != 100* ]]; then
|
||||
printf 'disconnected\t\t\t\n'
|
||||
if [[ $state != 100* ]]; then
|
||||
printf 'disconnected\t\t\t\n'
|
||||
return
|
||||
fi
|
||||
|
||||
printf 'wifi\t%s\t%s\t%s\n' "${ssid:-$device}" "$signal" "$freq"
|
||||
return
|
||||
fi
|
||||
|
||||
printf 'wifi\t%s\t%s\t%s\n' "${ssid:-$device}" "$signal" "$freq"
|
||||
printf 'wifi\t%s\t\t\n' "$device"
|
||||
}
|
||||
|
||||
ping_latency_ms() {
|
||||
|
||||
@@ -26,12 +26,8 @@ settle_seconds=${OMARCHY_OPENCLAW_ONBOARD_SETTLE_SECONDS:-3}
|
||||
# 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
|
||||
timeout 10 openclaw dashboard --json 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
|
||||
@@ -85,7 +81,7 @@ config_applied() {
|
||||
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
|
||||
json=$(timeout 10 openclaw dashboard --json 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
|
||||
|
||||
+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
|
||||
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"
|
||||
@@ -1013,7 +1013,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 +1092,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 ~/.hermes/skills
|
||||
for skill in "$OMARCHY_PATH"/default/agents/skills/*/; do
|
||||
skill=${skill%/}
|
||||
name=${skill##*/}
|
||||
@@ -92,7 +92,6 @@ for skill in "$OMARCHY_PATH"/default/agents/skills/*/; do
|
||||
ln -sfn "$skill" ~/.claude/skills/"$name"
|
||||
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
|
||||
|
||||
+14
-38
@@ -1,50 +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
|
||||
|
||||
sudo cp -f /etc/pacman.conf /etc/pacman.conf.bak
|
||||
sudo cp -f /etc/pacman.d/mirrorlist /etc/pacman.d/mirrorlist.bak
|
||||
|
||||
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, 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
|
||||
# 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,17 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Remove the ChatGPT desktop app along with its configuration and caches.
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
set -e
|
||||
|
||||
omarchy-pkg-drop openai-codex-desktop
|
||||
|
||||
# Not ~/.cache/codex-runtimes: the Codex CLI resolves its own runtime under
|
||||
# there, and it ships in a different package that survives this one.
|
||||
rm -rf \
|
||||
"$HOME/.config/Codex" \
|
||||
"$HOME/.cache/Codex"
|
||||
|
||||
echo ""
|
||||
echo "ChatGPT has been removed."
|
||||
@@ -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,16 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Remove Grok Bot along with its settings and data.
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
set -e
|
||||
|
||||
omarchy-pkg-drop grok-bot
|
||||
|
||||
# Not ~/.grok: that is the Grok CLI, which is installed and used on its own.
|
||||
rm -rf \
|
||||
"$HOME/.config/Grok Bot" \
|
||||
"$HOME/.grokbot"
|
||||
|
||||
echo ""
|
||||
echo "Grok Bot has been removed."
|
||||
Loaded 100 of 801 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user