Reorder the agents in the panel, count Grok's tokens, and install Grok through mise (#14004)

* Let the agents in the panel be put in any order

Drag an agent by its mark to move its section; the header it will land
on lights up, and the move happens on release. Each agent's header is
now a keyboard stop with its own highlight, and Ctrl+Up/Down moves the
agent the cursor is in. The order is kept in agents/order.json beside
the usage records. The key catcher turns Ctrl+Up/Down into a reorder
only for panels that opt in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Light only an agent's mark when the cursor or a drag is on it

The mark is the handle the agent moves by, so the keyboard cursor and
the drop spot while dragging box it alone rather than the header line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a lit agent mark's box from being clipped at the panel's edge

The box overhangs the content's left edge, which the scrolling area
clipped. The scrolling area now reaches a little into the panel's
padding with the content shifted back, so nothing moves and the box
draws whole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Reuse a Grok session scan only on the day it was made

A limits-only refresh just after midnight reused a scan from the evening
before, which counted yesterday's sessions as today's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep Ctrl+Up/Down from moving an agent when the cursor is outside one

The hero's buttons and the starter tiles carry indices too, and the
lookup read them as accounts, so with several accounts Ctrl+Up/Down on
one of them moved an unrelated agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Report a failed mise update even after the Grok upkeep runs

The Grok block ran after `mise up` and its last command set the script's
status, so a failed tool update could read as a success to callers that
warn about it. The update's own status is now the script's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Unpack a Grok update into ~/.grok whatever GROK_HOME says

The upkeep replaces ~/.grok's link, but the npm launcher unpacks into
GROK_HOME when it's set, so with a custom home the link never came back
at the new release and the old one was restored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hold off reordering agents while an account name is being edited

Ctrl+Up/Down reached the key catcher during an inline rename, and moving
the agent rebuilt its section, dropping the unfinished name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Install Grok through mise's first-party package

The npm launcher keeps an old ~/.grok/bin binary because mise skips its
postinstall. Use mise's grok tool, and drop the npm tool so its shim
does not stay ahead of the stub.

* Drop the npm Grok workarounds now that mise installs Grok itself

With Grok installed through mise's first-party package, the CLI is the
binary mise manages, so the update upkeep that repointed ~/.grok/bin and
the shared bin directory for added Grok accounts have nothing left to do.
omarchy-update-mise is back to running mise up and nothing else, and
adding a first Grok account installs the same package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Count Grok's tokens and prompts from its usage ledger

Each Grok session keeps a usage.json, the ledger `grok usage` prints, with
every finished turn's end time and tokens by model. The collector now
reads it for tokens today, by day for the last week, and by model, with
cached input kept apart, and counts today's prompts by the turns that
ended today. Found in #12352's research.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Count cache writes in Grok's daily token totals

Grok's totalTokens leaves cache writes out while the per-model buckets
count them, so a turn with cache writes added less to its day than to
its model. The day's total is now the sum of those same buckets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Remove the npm launcher's old Grok binaries when moving to mise's Grok

Omarchy's npm wrapper ran the binary the launcher unpacked into
~/.grok/bin, where x.ai's installer also puts a copy with a PATH entry
ahead of mise. Once the wrapper is replaced, a binary left there would
keep shadowing the mise tool, so the migration removes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Jesse Miller <jmiller@jmiller.com>
This commit is contained in:
authored and GitHub committed 2026-10-02 03:34:05 +02:00
1 parent f45461a38f
commit 821ae58905
17 files changed
+446 -120

No files matched your search

+1 -1
View File
@@ -56,7 +56,7 @@ 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="npm:@xai-official/grok"; home_env="GROK_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
+1 -3
View File
@@ -55,9 +55,7 @@ PROVIDERS = {
"grok": {
"name": "Grok",
"home_env": "GROK_HOME",
# bin holds the unpacked CLI itself: sharing it spares every added account
# its own 160MB copy, which the npm launcher unpacks into an empty home.
"shared_dirs": ["bin", "sessions", "skills", "agents", "plugins", "installed-plugins", "marketplace-cache", "hooks", "memory", "memory-v2", "rules", "commands", "workflows", "personas", "worktrees"],
"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": [],
},
+70 -26
View File
@@ -6,10 +6,10 @@
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 and prompts come from the summary Grok
keeps beside each session; it records no token counts there, nor when each
prompt was sent, so neither tokens nor today's prompts are claimed. The
agents panel only ever reads the JSON this prints.
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
@@ -20,7 +20,7 @@ import tempfile
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from pathlib import Path
AGENT_ID = "grok"
@@ -74,44 +74,88 @@ def default_home():
return Path(os.environ.get("GROK_HOME") or (Path.home() / ".grok"))
# Every session's summary, under <home>/sessions/<folder>/<session>/. Added
# accounts link their sessions to the primary's, so this covers all of them.
# A resumed session's prompts can't be told apart by day, so today counts
# sessions only.
# 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):
today = datetime.now().astimezone().date().isoformat()
prompts = sessions = today_sessions = 0
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()
for summary_path in (Path(home) / "sessions").glob("*/*/summary.json"):
summary = read_json(summary_path)
if not isinstance(summary, dict):
continue
active = parse_time(summary.get("last_active_at"))
if not active:
continue
day = active.astimezone().date().isoformat()
sessions += 1
prompts += int(summary.get("num_messages") or 0)
days.add(day)
if day == today:
today_sessions += 1
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 {}
if cached.get("home") == str(home) and time.time() - float(cached.get("at") or 0) < max_age:
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), "at": time.time(), "stats": stats})
write_json(cache, {"home": str(home), "day": today, "at": time.time(), "stats": stats})
return stats
+1 -1
View File
@@ -31,7 +31,7 @@ ori | openrouter) agent="ori"; name="Ori"; agent_package="github:OpenRouterLabs/
claude | claude-code) agent="claude"; name="Claude Code" ;;
codex) agent="codex"; name="Codex" ;;
crush) agent="crush"; name="Crush" ;;
grok) agent="grok"; name="Grok"; agent_package="npm:@xai-official/grok" ;;
grok) agent="grok"; name="Grok" ;;
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" ;;
-21
View File
@@ -9,25 +9,4 @@ if omarchy-cmd-present mise; then
# days after they ship. Running omarchy update is the user asking for current
# versions now, which is the same call the mup alias makes by hand.
MISE_MINIMUM_RELEASE_AGE=0 mise up
# Grok's npm launcher runs ~/.grok/bin/grok whenever it exists, and the
# package's install script that points it at a new release doesn't run
# under mise, so updates never took effect. Dropping a link to an older
# release lets the launcher unpack the installed one, which --version does
# now rather than on the next session. The old release stays until the new
# one is in place, and comes back if it never arrives.
if grok_dir=$(mise where npm:@xai-official/grok 2>/dev/null) && [[ -n $grok_dir ]]; then
grok_link="$HOME/.grok/bin/grok"
grok_current="grok-${grok_dir##*/}"
grok_previous=$(readlink "$grok_link" 2>/dev/null)
if [[ -L $grok_link && $grok_previous != "$grok_current" ]]; then
rm -f "$grok_link"
mise x npm:@xai-official/grok -- grok --version >/dev/null 2>&1
if [[ $(readlink "$grok_link" 2>/dev/null) == "$grok_current" ]]; then
find "$HOME/.grok/bin" -maxdepth 1 -name 'grok-*' ! -name "$grok_current" -delete
else
ln -sfn "$grok_previous" "$grok_link"
fi
fi
fi
fi
+1 -1
View File
@@ -12,7 +12,7 @@ omarchy-mise-install opencode
omarchy-mise-install npm:playwright playwright
omarchy-mise-install pi
omarchy-mise-install github:can1357/oh-my-pi omp
omarchy-mise-install npm:@xai-official/grok grok
omarchy-mise-install grok
# Cursor's own installer links the same path, so a re-provision keeps it.
omarchy-cmd-missing cursor-agent && omarchy-mise-install cursor-agent
omarchy-mise-install npm:@kitlangton/ghui ghui
+4 -2
View File
@@ -10,7 +10,7 @@ Omarchy treats AI coding agents as first-class citizens, but it doesn't pick a f
| `agy` | [Google Antigravity CLI](https://github.com/google-antigravity/antigravity-cli) |
| `copilot` | [GitHub Copilot CLI](https://github.com/github/copilot-cli) |
| `crush` | [Crush](https://github.com/charmbracelet/crush) |
| `grok` | Grok CLI from xAI |
| `grok` | [Grok Build](https://x.ai/build) from xAI |
| `pi` | [Mario Zechner's Pi](https://github.com/badlogic/pi-mono) |
| `omp` | [Oh My Pi](https://github.com/can1357/oh-my-pi) |
| `ori` | [Ori](https://openrouter.ai/docs/guides/ori/harness), OpenRouter's harness |
@@ -22,6 +22,8 @@ Omarchy treats AI coding agents as first-class citizens, but it doesn't pick a f
To wrap an additional CLI the same way, run `omarchy-mise-install <package> [command-name]`. The stubs are kept current along with everything else mise manages when you run `omarchy update` (or the `mup` alias).
`grok` is installed through mise's first-party `grok` package, the same way as `claude` and `codex`. Run `grok`, or pick it under _Setup > Defaults > Agent_. Skip the `curl | bash` installer on x.ai. That path puts a second copy in `~/.grok/bin`, prepends it on `PATH`, and overwrites Omarchy's wrapper.
### The default agent
Pick your default agent with `omarchy default agent <name>` or under _Setup > Defaults > Agent_ in the Omarchy Menu (`Super + Space`). If the agent isn't installed yet, picking it installs it first. A fresh Omarchy will invite you to make this choice with a one-time notification.
@@ -60,7 +62,7 @@ Crashes can also be silenced one program at a time, which is what the diagnosis
### Desktop apps
The _Install > AI_ menu also carries a few graphical AI apps: the ChatGPT desktop app, the Claude desktop app (Anthropic's Linux beta, with Chat, Cowork, and Claude Code tabs), Grok Bot for chatting with xAI's models, Hermes Desktop, OpenClaw, and the Perplexity desktop app.
The _Install > AI_ menu also carries a few graphical AI apps: the ChatGPT desktop app, the Claude desktop app (Anthropic's Linux beta, with Chat, Cowork, and Claude Code tabs), Grok Bot for chatting with xAI's models, Hermes Desktop, OpenClaw, and the Perplexity desktop app. Grok Bot is the desktop chat app. The coding agent is the `grok` command.
Hermes Desktop is the one to know about, because a machine has one Hermes, and it is the app's: Hermes is only ever installed through the app, and the `hermes` command the default agent runs is the app's. Choosing Hermes as the default agent installs the same thing the _Install > AI_ entry does: the package, and a Hermes runtime under `~/.hermes` set up by Hermes' own installer, which takes a few minutes the first time. That runtime is the one Hermes the terminal `hermes` command, the default agent and the app all use, and it updates itself with `hermes update` rather than waiting on an Omarchy release. Installing it also hands Hermes the Omarchy theme as a skin named `omarchy`, which every Hermes surface follows as you switch themes; pick another under Hermes' Appearance settings or with `/skin` if you'd rather it didn't, and Omarchy leaves that choice alone. Removing the app under _Remove > AI_ closes Hermes first, gateway included, takes that runtime with it, and keeps your chats, memories, and the skills Hermes wrote for itself unless you tell it otherwise: it asks, defaulting to no, whether that data and your connection settings should go too.
+61
View File
@@ -0,0 +1,61 @@
echo "Install Grok through mise's first-party package"
# The npm package launches whatever is already in ~/.grok/bin, and mise does not
# run its postinstall, so a newer package keeps running the old binary. mise's
# grok tool is the binary. Drop the npm tool or its shim stays ahead of the stub.
npm_grok="npm:@xai-official/grok"
wrapper="$HOME/.local/bin/grok"
npm_stub=false
# Anchored to the package the old installer wrote. A script the user wrote that
# calls mise use -g for something else is left alone.
if [[ -f $wrapper && ! -L $wrapper ]] && grep -Fq "$npm_grok" "$wrapper"; then
npm_stub=true
fi
drop_npm_grok() {
omarchy-cmd-present mise || return 0
if [[ -n $(mise ls -g "$npm_grok") ]]; then
mise unuse -g "$npm_grok"
fi
if [[ -n $(mise ls -i "$npm_grok") ]]; then
mise uninstall -y --all "$npm_grok"
fi
}
if [[ ! -f $HOME/.local/state/omarchy/preinstalls-removed ]]; then
# The shim is ahead of ~/.local/bin, so drop the npm tool before looking for
# grok. A removed wrapper still looks installed until the tool is gone.
drop_npm_grok
if [[ $npm_stub == true ]] || omarchy-cmd-missing grok; then
omarchy-mise-install grok
fi
elif [[ $npm_stub == true ]]; then
# After an opt-out, only a wrapper Omarchy wrote proves the tool is ours.
rm -f "$wrapper"
drop_npm_grok
fi
# The npm launcher unpacked its binary into ~/.grok/bin, where x.ai's installer
# also puts its copy and a PATH entry ahead of mise. With Omarchy's wrapper
# gone, nothing of ours runs from there, and a copy left behind would keep
# shadowing the mise tool.
if [[ $npm_stub == true ]]; then
rm -f "$HOME/.grok/bin/grok" "$HOME"/.grok/bin/grok-[0-9]*
fi
# The x.ai installer links ~/.local/bin/agent at its copy under ~/.grok.
# realpath -m resolves a relative or dangling target without requiring it to exist.
if [[ -L $HOME/.local/bin/agent ]]; then
agent_target=$(readlink "$HOME/.local/bin/agent" || true)
agent_resolved=""
if [[ $agent_target == /* ]]; then
agent_resolved=$(realpath -m "$agent_target")
elif [[ -n $agent_target ]]; then
agent_resolved=$(realpath -m "$HOME/.local/bin/$agent_target")
fi
if [[ $agent_resolved == "$HOME/.grok" || $agent_resolved == "$HOME/.grok/"* ]]; then
rm -f "$HOME/.local/bin/agent"
fi
fi
+11
View File
@@ -34,8 +34,12 @@ Item {
id: root
property bool blocked: false
// Panels that let items be reordered set this, and Ctrl+Up/Down (or
// Ctrl+k/j) then asks to move the current item instead of the cursor.
property bool reorderable: false
signal moveRequested(int dx, int dy)
signal reorderRequested(int dy)
signal activateRequested()
signal returnRequested()
signal closeRequested()
@@ -57,6 +61,13 @@ Item {
event.accepted = true
return
}
if (reorderable && (event.modifiers & Qt.ControlModifier)) {
var up = event.key === Qt.Key_Up || event.key === Qt.Key_K
var down = event.key === Qt.Key_Down || event.key === Qt.Key_J
if (up || down) {
reorderRequested(down ? 1 : -1); event.accepted = true; return
}
}
if (event.key === Qt.Key_Down || event.text === "j") {
moveRequested(0, 1); event.accepted = true; return
}
+46 -1
View File
@@ -226,7 +226,52 @@ Item {
var syncedDisplay = displayProvider({ id: syncedId, name: stats.providerName || syncedId })
if (providerHasData(syncedDisplay)) result.push(syncedDisplay)
}
return result
return orderedProviders(result)
}
// ----------------------------------------------------------------- order
//
// The panel's agents in the order someone dragged them into, kept beside the
// usage records. An agent the order doesn't name yet keeps its place after
// the ones it does.
readonly property string orderPath: (Quickshell.env("XDG_STATE_HOME") || home + "/.local/state") + "/omarchy/agents/order.json"
property var providerOrder: []
function orderedProviders(list) {
var order = providerOrder
var rank = function(p) {
var i = order.indexOf(p.providerId)
return i < 0 ? order.length : i
}
return list.slice().sort(function(a, b) { return rank(a) - rank(b) })
}
// Moves one agent to a position among the ones shown, and saves the result.
function moveProvider(id, to) {
var ids = enabledProviders.map(function(p) { return p.providerId })
var from = ids.indexOf(id)
if (from < 0) return
to = Math.max(0, Math.min(ids.length - 1, to))
if (from === to) return
ids.splice(to, 0, ids.splice(from, 1)[0])
for (var i = 0; i < providerOrder.length; i++)
if (ids.indexOf(providerOrder[i]) < 0) ids.push(providerOrder[i])
providerOrder = ids
orderFile.setText(JSON.stringify(ids) + "\n")
}
FileView {
id: orderFile
path: root.orderPath
watchChanges: true
atomicWrites: true
printErrors: false
onLoaded: {
var parsed = []
try { parsed = JSON.parse(text()) } catch (e) { parsed = [] }
root.providerOrder = Array.isArray(parsed) ? parsed.map(String) : []
}
onFileChanged: reload()
}
function providerEnabled(id) {
+97 -3
View File
@@ -46,8 +46,8 @@ Panel {
// The keyboard walks everything on the page that does something, in
// reading order, one row at a time: the hero's buttons, then each agent's
// Sign-in required link or switchable accounts, and the starter tiles, or
// the agents to add while picking one. Up and down change rows, left and right move along one.
// header, its Sign-in required link or switchable accounts, and the starter
// tiles, or the agents to add while picking one. Up and down change rows, left and right move along one.
// Hovering moves the same cursor, so only one thing is lit.
readonly property var keyRows: {
var rows = []
@@ -66,6 +66,8 @@ Panel {
// fix is skipped.
var entry = 0
for (var p = 0; p < providers.length; p++) {
// Every agent's header is a stop, so Ctrl+Up/Down can move it.
rows.push([{ kind: "provider", index: p }])
var accounts = providerAccounts(providers[p])
if (accounts.length < 2) {
if (needsSignIn(providers[p])) rows.push([{ kind: "providerSignin", index: p }])
@@ -135,6 +137,56 @@ Panel {
}
}
// The agent the cursor is in, by its position on the page.
function providerIndexOfKey() {
var target = keyTarget
if (!target) return -1
if (target.kind === "provider" || target.kind === "providerSignin") return target.index
if (["account", "autoswitch", "signin"].indexOf(target.kind) < 0) return -1
var entry = accountEntries[target.index]
return entry ? providers.indexOf(entry.provider) : -1
}
// Ctrl+Up/Down carries the agent the cursor is in up or down the page, and
// the cursor along with it.
function reorderProvider(dy) {
var from = providerIndexOfKey()
if (from < 0) return
var to = clamp(from + dy, 0, providers.length - 1)
if (to === from) return
usage.moveProvider(providers[from].providerId, to)
Qt.callLater(function() { pointAt("provider", to) })
}
// Dragging an agent by its mark lights the header it would land on, and
// moves it there on release: the sections are rebuilt when the order
// changes, which would drop a drag still in progress.
property string dragProviderId: ""
property int dragTarget: -1
// An account name being edited keeps Ctrl+Up/Down: moving its agent would
// rebuild the section and drop the unfinished name.
property bool renaming: false
function dragProviderOver(y) {
for (var i = 0; i < providerSections.count; i++) {
var item = providerSections.itemAt(i)
if (item && y >= item.y && y < item.y + item.height) {
dragTarget = i
return
}
}
}
function dropProvider() {
var id = dragProviderId
var to = dragTarget
dragProviderId = ""
dragTarget = -1
if (id === "" || to < 0) return
usage.moveProvider(id, to)
Qt.callLater(function() { pointAt("provider", to) })
}
// Keeps whatever the cursor lands on inside the scrolled view.
function revealItem(item) {
if (!item || !panelFlick || !panelFlick.interactive) return
@@ -827,6 +879,8 @@ Panel {
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
reorderable: root.addStage === "" && !root.renaming
onReorderRequested: function(dy) { root.reorderProvider(dy) }
onMoveRequested: function(dx, dy) {
// Naming and signing in have their own fields; there the arrows scroll.
@@ -849,7 +903,12 @@ Panel {
Flickable {
id: panelFlick
// Reaches a little into the panel's padding on the left, with the
// content shifted back, so the box around a lit agent mark isn't
// clipped where it overhangs the content's edge.
readonly property real overhang: Style.space(8)
anchors.fill: parent
anchors.leftMargin: -overhang
contentWidth: width
contentHeight: column.implicitHeight
clip: true
@@ -863,9 +922,10 @@ Panel {
Column {
id: column
x: panelFlick.overhang
// When the panel scrolls, the bar gets its own strip rather than
// sitting on top of the right-aligned numbers.
width: panelFlick.width - (panelFlick.interactive ? panelScroll.width + Style.space(6) : 0)
width: panelFlick.width - panelFlick.overhang - (panelFlick.interactive ? panelScroll.width + Style.space(6) : 0)
spacing: Style.space(16)
// ---------- Hero: agents · rotating summary · add ----------
@@ -920,6 +980,7 @@ Panel {
}
Repeater {
id: providerSections
model: root.addStage === "" ? root.providers : []
ProviderSection {
@@ -1186,18 +1247,50 @@ Panel {
readonly property var windows: root.displayWindows(provider)
readonly property var balance: provider ? (provider.balance || null) : null
spacing: Style.space(16)
opacity: root.dragProviderId !== "" && provider && root.dragProviderId === provider.providerId ? 0.5 : 1.0
PanelSeparator { foreground: root.foreground }
Item {
id: sectionHead
width: parent.width
implicitHeight: Math.max(sectionMark.height, sectionName.implicitHeight)
// Lit for the keyboard, and as the drop spot while an agent is dragged.
readonly property bool lit: root.dragProviderId !== "" ? root.dragTarget === section.providerIndex : root.hasKey("provider", section.providerIndex)
onLitChanged: if (lit && root.dragProviderId === "") root.revealItem(sectionHead)
// Only the mark is lit: it's the handle the agent moves by.
CursorSurface {
anchors.fill: sectionMark
anchors.margins: -Style.space(5)
z: -1
hasCursor: sectionHead.lit
foreground: root.foreground
}
ProviderIcon {
id: sectionMark
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
provider: section.provider
// The mark is the handle for dragging the agent up or down the page.
MouseArea {
anchors.fill: parent
anchors.margins: -Style.space(4)
hoverEnabled: true
preventStealing: true
cursorShape: pressed ? Qt.ClosedHandCursor : Qt.OpenHandCursor
onPressed: {
root.dragProviderId = section.provider ? section.provider.providerId : ""
root.dragTarget = section.providerIndex
}
onPositionChanged: function(mouse) {
if (pressed) root.dragProviderOver(mapToItem(column, mouse.x, mouse.y).y)
}
onReleased: root.dropProvider()
onCanceled: { root.dragProviderId = ""; root.dragTarget = -1 }
}
}
Text {
@@ -1552,6 +1645,7 @@ Panel {
readonly property string label: renamedTo !== "" ? renamedTo : String(account.label || account.id || "")
onAccountChanged: renamedTo = ""
onEditingChanged: root.renaming = editing
onPickedChanged: if (picked) root.revealItem(head)
// Anywhere on the line counts, so Use can show up when it's hidden.
+7 -4
View File
@@ -72,7 +72,7 @@ light surfaces — and the bar glyph stands in when there is none.
|---|---|---|
| `claude` | Anthropic's OAuth usage endpoint (5-hour session + 7-day weekly) | `~/.claude/projects` transcripts, opencode sessions on an Anthropic provider, plus `stats-cache.json` and `history.jsonl` as fallback |
| `codex` | The Codex app-server RPC | native Codex CLI session files (plus pi and opencode sessions) |
| `grok` | The credits endpoint behind Grok's `/usage` view (the billing period's included usage) | Prompts and sessions from each session's `summary.json` (Grok records no tokens there) |
| `grok` | The credits endpoint behind Grok's `/usage` view (the billing period's included usage) | Each session's `usage.json` (the ledger `grok usage` prints: tokens by model per finished turn), plus `summary.json` for sessions |
| `fireworks` | Estimated prepaid balance: configured funding minus rated account costs | Fireworks billing API, grouped by day and model for the last 30 days |
When `~/.local/state/omarchy/agents/accounts/<claude|codex|grok>.json`
@@ -135,9 +135,12 @@ only adds the meter and the spent-of-funded line under the real figure.
urgent when any account new sessions use is at 90% of a window, or a
prepaid balance is down to its last 10%.
- Panel: the arrows (or `h`/`j`/`k`/`l`) walk a cursor over everything that
does something, row by row: the hero's buttons, each switchable account
(landing on Use, with Autoswitch to its left), and the starter tiles, or the
agents to add. Hovering moves the same cursor. Enter acts on it, or
does something, row by row: the hero's buttons, each agent's header, each
switchable account (landing on Use, with Autoswitch to its left), and the
starter tiles, or the agents to add. Ctrl+Up/Down (or Ctrl+`k`/`j`) moves the
agent the cursor is in up or down the page; dragging an agent by its mark
does the same, lighting the header it will land on. The order is kept in
`~/.local/state/omarchy/agents/order.json`. Hovering moves the same cursor. Enter acts on it, or
refreshes when nothing is lit; `r` refreshes, Tab moves to the neighboring
bar panel, Esc closes.
- Accounts: `1`–`9` jump to an account across every agent, and Enter makes it
+2 -3
View File
@@ -192,13 +192,12 @@ OMARCHY_TEST_DEFAULT_AGENT="" omarchy-agent-account-add grok </dev/null >/dev/nu
fail "a first Grok sign-in lands in ~/.grok through the normal browser"
pass "Grok signs in its first account"
mkdir -p "$HOME/.grok/bin" && touch "$HOME/.grok/bin/grok"
OMARCHY_TEST_LOGIN_UUID=u-grok-2 OMARCHY_TEST_LOGIN_EMAIL=side@example.com \
omarchy-agent-account-add grok Side </dev/null >/dev/null
[[ $(omarchy-agent-account-list grok --json | jq -c '.[0].accounts[1] | {id, email, plan}') == '{"id":"side","email":"side@example.com","plan":"SuperGrok"}' ]] ||
fail "a Grok account reads its identity and plan from its own home" "$(omarchy-agent-account-list grok --json)"
[[ $(readlink "$accounts/grok/side/bin") == "$HOME/.grok/bin" && $(readlink "$accounts/grok/side/sessions") == "$HOME/.grok/sessions" ]] ||
fail "a Grok account shares the CLI and sessions with the primary"
[[ $(readlink "$accounts/grok/side/sessions") == "$HOME/.grok/sessions" ]] ||
fail "a Grok account shares sessions with the primary"
grep -qx -- "--private https://auth.x.ai/oauth/authorize" "$OMARCHY_TEST_BROWSER_LOG" ||
fail "a second Grok login opens in a private window" "$(cat "$OMARCHY_TEST_BROWSER_LOG")"
omarchy-agent-account-use grok side >/dev/null
+21 -5
View File
@@ -102,17 +102,25 @@ record=$(collect)
fail "every Grok account reports its own plan and credits" "$record"
pass "every Grok account reports its own plan and credits"
# Prompts and sessions come from each session's summary; no tokens are claimed.
# Sessions come from each session's summary; tokens and today's prompts from
# its usage.json, one entry per finished turn, with cached input kept apart.
sessions="$HOME/.grok/sessions/%2Fhome%2Fme"
now=$(python3 -c 'import datetime as dt; print(dt.datetime.now(dt.timezone.utc).isoformat())')
mkdir -p "$sessions/today-1" "$sessions/today-2" "$sessions/old"
jq -n --arg at "$now" '{last_active_at: $at, num_messages: 3, current_model_id: "grok-4.7"}' >"$sessions/today-1/summary.json"
jq -n --arg at "$now" '{last_active_at: $at, num_messages: 3}' >"$sessions/today-1/summary.json"
jq -n --arg at "$now" '{last_active_at: $at, num_messages: 2}' >"$sessions/today-2/summary.json"
jq -n '{last_active_at: "2026-01-02T10:00:00Z", num_messages: 5}' >"$sessions/old/summary.json"
jq -n --arg at "$now" '{turns: [
{endedAt: $at, modelUsage: {"grok-4.7-build": {inputTokens: 1000, cachedReadTokens: 800, outputTokens: 50, cacheCreationTokens: 0, totalTokens: 1050}}},
{endedAt: $at, modelUsage: {"grok-4.7-build": {inputTokens: 200, cachedReadTokens: 100, outputTokens: 10, cacheCreationTokens: 5, totalTokens: 210}}}
]}' >"$sessions/today-1/usage.json"
jq -n '{turns: [{endedAt: "2026-01-02T10:00:00Z", modelUsage: {"grok-4.6": {inputTokens: 500, cachedReadTokens: 0, outputTokens: 20, cacheCreationTokens: 0, totalTokens: 520}}}]}' >"$sessions/old/usage.json"
record=$(collect)
[[ $(jq -c '{hasLocalStats, todaySessions, totalPrompts, totalSessions, activeDays, claimed: (has("todayTotalTokens") or has("todayPrompts"))}' <<<"$record") == '{"hasLocalStats":true,"todaySessions":2,"totalPrompts":10,"totalSessions":3,"activeDays":2,"claimed":false}' ]] ||
fail "Grok counts prompts and sessions from its session summaries" "$record"
pass "Grok counts prompts and sessions from its session summaries"
[[ $(jq -c '{hasLocalStats, todayPrompts, todaySessions, totalPrompts, totalSessions, activeDays, todayTotalTokens, todayTokensByModel, today: .recentDays[6].messageCount}' <<<"$record") == '{"hasLocalStats":true,"todayPrompts":2,"todaySessions":2,"totalPrompts":10,"totalSessions":3,"activeDays":2,"todayTotalTokens":1265,"todayTokensByModel":{"grok-4.7-build":1265},"today":1265}' ]] ||
fail "Grok counts sessions, prompts, and tokens from its session files" "$record"
[[ $(jq -cS '.modelUsage' <<<"$record") == '{"grok-4.6":{"cacheCreationInputTokens":0,"cacheReadInputTokens":0,"inputTokens":500,"outputTokens":20},"grok-4.7-build":{"cacheCreationInputTokens":5,"cacheReadInputTokens":900,"inputTokens":300,"outputTokens":60}}' ]] ||
fail "Grok's tokens by model keep cached input apart" "$record"
pass "Grok counts sessions, prompts, and tokens from its session files"
# A limits-only refresh reuses the last scan rather than reading every summary.
mkdir -p "$sessions/later"
@@ -120,3 +128,11 @@ jq -n --arg at "$now" '{last_active_at: $at, num_messages: 1}' >"$sessions/later
record=$(COLLECT_ARGS="--limits-only" collect)
[[ $(jq -r '.totalSessions' <<<"$record") == 3 ]] || fail "a limits-only refresh reuses the session scan" "$record"
pass "a limits-only refresh reuses the session scan"
# A scan from another day is never reused, so yesterday's sessions don't
# count as today's after midnight.
jq '.day = "2000-01-01"' "$XDG_CACHE_HOME/omarchy/agent-usage/grok-stats.json" >"$test_tmp/stats.json"
mv "$test_tmp/stats.json" "$XDG_CACHE_HOME/omarchy/agent-usage/grok-stats.json"
record=$(COLLECT_ARGS="--limits-only" collect)
[[ $(jq -r '.totalSessions' <<<"$record") == 4 ]] || fail "a scan from another day is made again" "$record"
pass "a scan from another day is made again"
+7
View File
@@ -33,4 +33,11 @@ assert(!/t === "a" \|\| t === "A"/.test(panelSource), 'adding an account has no
assert(/if \(!accounts\[a\]\.active\) \{/.test(panelSource) && /if \(row\.length > 0\) rows\.push\(row\)/.test(panelSource), 'the active account with nothing to fix is not a keyboard stop')
const mainSource = fs.readFileSync(root + '/shell/plugins/agents/Main.qml', 'utf8')
assert(/var from = Math\.min\(0\.8, \(threshold - 15\) \/ 100\)/.test(mainSource), 'faster checks start 15 points below the switch threshold')
assert(/rows\.push\(\[\{ kind: "provider", index: p \}\]\)/.test(panelSource), "every agent's header is a keyboard stop")
assert(/reorderable: root\.addStage === "" && !root\.renaming/.test(panelSource) && /onEditingChanged: root\.renaming = editing/.test(panelSource) && /onReorderRequested: function\(dy\) \{ root\.reorderProvider\(dy\) \}/.test(panelSource), 'Ctrl+Up/Down moves the agent the cursor is in')
assert(/onReleased: root\.dropProvider\(\)/.test(panelSource), 'an agent can be dragged by its mark')
assert(/function moveProvider\(id, to\)/.test(mainSource) && /return orderedProviders\(result\)/.test(mainSource), 'the agents keep the order they were moved into')
const catcherSource = fs.readFileSync(root + '/shell/Ui/PanelKeyCatcher.qml', 'utf8')
assert(/if \(reorderable && \(event\.modifiers & Qt\.ControlModifier\)\)/.test(catcherSource), 'only panels that ask for it turn Ctrl+Up/Down into a reorder')
assert(/if \(\["account", "autoswitch", "signin"\]\.indexOf\(target\.kind\) < 0\) return -1/.test(panelSource), 'Ctrl+Up/Down does nothing outside an agent')
JS
+116 -3
View File
@@ -71,6 +71,11 @@ if [[ $1 == "where" ]]; then
exit
fi
if [[ $1 == "ls" && -n ${OMARCHY_TEST_MISE_HAS_NPM_GROK:-} ]]; then
printf '%s\n' "npm:@xai-official/grok 1.0.44"
exit 0
fi
[[ ${OMARCHY_TEST_MISE_FAIL:-false} != "true" ]]
SH
@@ -120,7 +125,8 @@ export OMARCHY_TEST_AGENT_MENU_LOG="$menu_log"
export OMARCHY_TEST_MUSE_LOGIN_LOG="$muse_login_log"
export OMARCHY_PATH="$ROOT"
grok_package="npm:@xai-official/grok"
grok_package="grok"
legacy_grok_package="npm:@xai-official/grok"
omp_package="github:can1357/oh-my-pi"
crush_package="crush"
agy_package="antigravity-cli"
@@ -151,7 +157,7 @@ pass "custom agent lazy stubs preserve their mise packages"
OMARCHY_TEST_MISSING_COMMAND=cursor-agent source "$ROOT/install/user/mise.sh"
grep -Fx "$agy_package agy" "$stub_log" >/dev/null || fail "user setup creates the Antigravity lazy stub"
grep -Fx "$grok_package grok" "$stub_log" >/dev/null || fail "user setup creates the Grok lazy stub"
grep -Fx "$grok_package" "$stub_log" >/dev/null || fail "user setup creates the Grok lazy stub"
grep -Fx "$cursor_agent_package" "$stub_log" >/dev/null || fail "user setup creates the Cursor CLI lazy stub"
grep -Fx "$omp_package omp" "$stub_log" >/dev/null || fail "user setup creates the Oh My Pi lazy stub"
grep -Fx "$crush_package" "$stub_log" >/dev/null || fail "user setup creates the Crush lazy stub"
@@ -202,7 +208,7 @@ pass "Cursor CLI migration preserves an existing Cursor CLI install"
: >"$stub_log"
source "$ROOT/migrations/1785846769.sh" >/dev/null
grep -Fx "$omp_package omp" "$stub_log" >/dev/null || fail "agent migration repairs the Oh My Pi lazy stub"
grep -Fx "$grok_package grok" "$stub_log" >/dev/null || fail "agent migration creates the Grok lazy stub"
grep -Fx "$legacy_grok_package grok" "$stub_log" >/dev/null || fail "agent migration creates the Grok lazy stub"
grep -Fx "$crush_package" "$stub_log" >/dev/null || fail "agent migration creates the Crush lazy stub"
: >"$stub_log"
@@ -302,8 +308,115 @@ source "$ROOT/migrations/1785846769.sh" >/dev/null
fail "agent migration keeps a wrapper built on $omp_package"
rm -f "$test_home/.local/bin/omp"
printf '#!/bin/bash\nexport MISE_MINIMUM_RELEASE_AGE=0\nmise use -g --quiet "%s" || exit 1\nexec mise x "%s" -- "grok" "$@"\n' \
"$legacy_grok_package" "$legacy_grok_package" >"$test_home/.local/bin/grok"
chmod +x "$test_home/.local/bin/grok"
mkdir -p "$test_home/.grok/bin"
ln -s "$test_home/.grok/bin/agent" "$test_home/.local/bin/agent"
: >"$stub_log"
: >"$mise_history"
OMARCHY_TEST_MISE_HAS_NPM_GROK=1 source "$ROOT/migrations/1790863209.sh" >/dev/null
unset OMARCHY_TEST_MISE_HAS_NPM_GROK
[[ ! -s $stub_log ]] || fail "Grok registry migration respects the preinstall opt-out"
[[ ! -e $test_home/.local/bin/grok ]] || fail "Grok registry migration removes the npm wrapper after opt-out"
[[ ! -e $test_home/.local/bin/agent ]] || fail "Grok registry migration removes the curl-installer agent symlink"
grep -Fx "unuse -g $legacy_grok_package" "$mise_history" >/dev/null ||
fail "Grok registry migration drops the npm tool after opt-out"
grep -Fx "uninstall -y --all $legacy_grok_package" "$mise_history" >/dev/null ||
fail "Grok registry migration uninstalls the npm tool after opt-out"
ln -s "../../.grok/bin/agent" "$test_home/.local/bin/agent"
source "$ROOT/migrations/1790863209.sh" >/dev/null
[[ ! -e $test_home/.local/bin/agent ]] ||
fail "Grok registry migration removes a relative dangling agent symlink"
ln -s /usr/bin/true "$test_home/.local/bin/agent"
source "$ROOT/migrations/1790863209.sh" >/dev/null
[[ -L $test_home/.local/bin/agent ]] ||
fail "Grok registry migration keeps an agent symlink that is not Grok's"
rm -f "$test_home/.local/bin/agent"
rm -f "$test_home/.local/bin/grok"
: >"$stub_log"
: >"$mise_history"
OMARCHY_TEST_MISE_HAS_NPM_GROK=1 source "$ROOT/migrations/1790863209.sh" >/dev/null
unset OMARCHY_TEST_MISE_HAS_NPM_GROK
[[ ! -s $stub_log ]] || fail "Grok registry migration does not reinstall after the preinstall opt-out"
grep -Fx "unuse -g $legacy_grok_package" "$mise_history" >/dev/null &&
fail "Grok registry migration leaves a user-installed npm tool after the preinstall opt-out"
rm "$test_home/.local/state/omarchy/preinstalls-removed"
rm -f "$agent_file"
printf '#!/bin/bash\nexport MISE_MINIMUM_RELEASE_AGE=0\nmise use -g --quiet "%s" || exit 1\nexec mise x "%s" -- "grok" "$@"\n' \
"$legacy_grok_package" "$legacy_grok_package" >"$test_home/.local/bin/grok"
chmod +x "$test_home/.local/bin/grok"
: >"$stub_log"
: >"$mise_history"
mkdir -p "$test_home/.grok/bin"
touch "$test_home/.grok/bin/grok-1.0.44" "$test_home/.grok/bin/settings-keep"
ln -sfn grok-1.0.44 "$test_home/.grok/bin/grok"
OMARCHY_TEST_MISE_HAS_NPM_GROK=1 source "$ROOT/migrations/1790863209.sh" >/dev/null
unset OMARCHY_TEST_MISE_HAS_NPM_GROK
grep -Fx "$grok_package" "$stub_log" >/dev/null ||
fail "Grok registry migration rewrites the npm wrapper"
[[ ! -e $test_home/.grok/bin/grok && ! -e $test_home/.grok/bin/grok-1.0.44 && -e $test_home/.grok/bin/settings-keep ]] ||
fail "Grok registry migration removes the npm launcher's old binaries and nothing else"
rm -f "$test_home/.grok/bin/settings-keep"
grep -Fx "unuse -g $legacy_grok_package" "$mise_history" >/dev/null ||
fail "Grok registry migration drops the npm tool"
rm -f "$test_home/.local/bin/grok"
printf '#!/bin/bash\necho user-grok\n' >"$test_home/.local/bin/grok"
chmod +x "$test_home/.local/bin/grok"
: >"$stub_log"
: >"$mise_history"
OMARCHY_TEST_MISE_HAS_NPM_GROK=1 source "$ROOT/migrations/1790863209.sh" >/dev/null
unset OMARCHY_TEST_MISE_HAS_NPM_GROK
[[ $("$test_home/.local/bin/grok") == "user-grok" ]] ||
fail "Grok registry migration keeps a user-managed grok"
[[ ! -s $stub_log ]] || fail "Grok registry migration does not replace a user-managed grok"
grep -Fx "unuse -g $legacy_grok_package" "$mise_history" >/dev/null ||
fail "Grok registry migration drops the npm tool beside a user-managed grok"
cat >"$test_home/.local/bin/grok" <<'SH'
#!/bin/bash
mise use -g --quiet "grok" || exit 1
echo user-mise-grok
SH
chmod +x "$test_home/.local/bin/grok"
cp "$test_home/.local/bin/grok" "$test_tmp/user-mise-grok"
: >"$stub_log"
: >"$mise_history"
OMARCHY_TEST_MISE_HAS_NPM_GROK=1 source "$ROOT/migrations/1790863209.sh" >/dev/null
unset OMARCHY_TEST_MISE_HAS_NPM_GROK
cmp -s "$test_home/.local/bin/grok" "$test_tmp/user-mise-grok" ||
fail "Grok registry migration keeps a user script that calls mise"
[[ ! -s $stub_log ]] || fail "Grok registry migration does not replace a user script that calls mise"
grep -Fx "unuse -g $legacy_grok_package" "$mise_history" >/dev/null ||
fail "Grok registry migration drops the npm tool beside a user script"
rm -f "$test_home/.local/bin/grok"
rm -f "$test_home/.local/bin/grok"
: >"$stub_log"
: >"$mise_history"
OMARCHY_TEST_MISSING_COMMAND=grok source "$ROOT/migrations/1790863209.sh" >/dev/null
unset OMARCHY_TEST_MISSING_COMMAND
grep -Fx "$grok_package" "$stub_log" >/dev/null ||
fail "Grok registry migration installs the stub when grok is missing"
grep -Fx "unuse -g $legacy_grok_package" "$mise_history" >/dev/null &&
fail "Grok registry migration does not drop the npm tool when it is not installed"
rm -f "$test_home/.local/bin/grok"
: >"$stub_log"
: >"$mise_history"
OMARCHY_TEST_MISE_HAS_NPM_GROK=1 OMARCHY_TEST_MISSING_COMMAND=grok \
source "$ROOT/migrations/1790863209.sh" >/dev/null
unset OMARCHY_TEST_MISE_HAS_NPM_GROK OMARCHY_TEST_MISSING_COMMAND
grep -Fx "$grok_package" "$stub_log" >/dev/null ||
fail "Grok registry migration installs the stub after dropping a leftover npm tool"
grep -Fx "unuse -g $legacy_grok_package" "$mise_history" >/dev/null ||
fail "Grok registry migration drops a leftover npm tool when the wrapper is gone"
pass "agent migrations install working wrappers without overriding the preinstall opt-out"
"$ROOT/bin/omarchy-mise-install" "$muse_package" muse
-46
View File
@@ -1,46 +0,0 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
export HOME="$test_tmp/home"
mkdir -p "$HOME/.grok/bin" "$test_tmp/bin" "$test_tmp/installs/npm-xai-official-grok/1.0.44"
# mise reports the installed release by its directory; running grok under it
# stands in for the npm launcher unpacking that release.
cat >"$test_tmp/bin/mise" <<SH
#!/bin/bash
case "\$1" in
up) ;;
where) echo "$test_tmp/installs/npm-xai-official-grok/1.0.44" ;;
x) echo ran >"$test_tmp/grok-ran"; touch "\$HOME/.grok/bin/grok-1.0.44"; ln -sfn grok-1.0.44 "\$HOME/.grok/bin/grok" ;;
esac
SH
chmod +x "$test_tmp/bin/mise"
echo old >"$HOME/.grok/bin/grok-1.0.30"
ln -s grok-1.0.30 "$HOME/.grok/bin/grok"
PATH="$test_tmp/bin:$ROOT/bin:$PATH" "$ROOT/bin/omarchy-update-mise" >/dev/null
[[ $(readlink "$HOME/.grok/bin/grok") == "grok-1.0.44" && ! -e $HOME/.grok/bin/grok-1.0.30 ]] ||
fail "an update points Grok at the installed release and drops the old one" "$(ls -la "$HOME/.grok/bin")"
pass "an update points Grok at the installed release and drops the old one"
rm -f "$test_tmp/grok-ran"
PATH="$test_tmp/bin:$ROOT/bin:$PATH" "$ROOT/bin/omarchy-update-mise" >/dev/null
[[ ! -e $test_tmp/grok-ran ]] || fail "a current Grok is left alone"
pass "a current Grok is left alone"
# An unpack that never arrives leaves the old release running.
rm -f "$HOME/.grok/bin/grok" "$HOME/.grok/bin/grok-1.0.44"
echo old >"$HOME/.grok/bin/grok-1.0.30"
ln -s grok-1.0.30 "$HOME/.grok/bin/grok"
sed -i 's/^ x) .*/ x) exit 1 ;;/' "$test_tmp/bin/mise"
PATH="$test_tmp/bin:$ROOT/bin:$PATH" "$ROOT/bin/omarchy-update-mise" >/dev/null
[[ $(readlink "$HOME/.grok/bin/grok") == "grok-1.0.30" && -e $HOME/.grok/bin/grok-1.0.30 ]] ||
fail "a failed unpack keeps the old Grok release" "$(ls -la "$HOME/.grok/bin")"
pass "a failed unpack keeps the old Grok release"