From 821ae589059ffdadc970315f866c94b55d268af7 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 2 Oct 2026 03:34:05 +0200 Subject: [PATCH] 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Jesse Miller --- bin/omarchy-agent-account-add | 2 +- bin/omarchy-agent-account-state | 4 +- bin/omarchy-agent-usage-grok | 96 +++++++++++++++------ bin/omarchy-default-agent | 2 +- bin/omarchy-update-mise | 21 ----- install/user/mise.sh | 2 +- manual/17-ai.md | 6 +- migrations/1790863209.sh | 61 +++++++++++++ shell/Ui/PanelKeyCatcher.qml | 11 +++ shell/plugins/agents/Main.qml | 47 +++++++++- shell/plugins/agents/Panel.qml | 100 +++++++++++++++++++++- shell/plugins/agents/README.md | 11 ++- test/shell.d/agent-account-test.sh | 5 +- test/shell.d/agent-usage-grok-test.sh | 26 ++++-- test/shell.d/agents-panel-test.sh | 7 ++ test/shell.d/default-agent-test.sh | 119 +++++++++++++++++++++++++- test/shell.d/update-mise-grok-test.sh | 46 ---------- 17 files changed, 446 insertions(+), 120 deletions(-) create mode 100644 migrations/1790863209.sh delete mode 100755 test/shell.d/update-mise-grok-test.sh diff --git a/bin/omarchy-agent-account-add b/bin/omarchy-agent-account-add index 94631d50..d54bebad 100755 --- a/bin/omarchy-agent-account-add +++ b/bin/omarchy-agent-account-add @@ -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 ] [claude|codex|grok] [label]" >&2 exit 1 diff --git a/bin/omarchy-agent-account-state b/bin/omarchy-agent-account-state index ca0803dd..0578b6cc 100755 --- a/bin/omarchy-agent-account-state +++ b/bin/omarchy-agent-account-state @@ -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": [], }, diff --git a/bin/omarchy-agent-usage-grok b/bin/omarchy-agent-usage-grok index 79924275..09eea0e8 100755 --- a/bin/omarchy-agent-usage-grok +++ b/bin/omarchy-agent-usage-grok @@ -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 /sessions///. 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 /sessions///: 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 diff --git a/bin/omarchy-default-agent b/bin/omarchy-default-agent index 4cf77f79..684baaf2 100755 --- a/bin/omarchy-default-agent +++ b/bin/omarchy-default-agent @@ -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" ;; diff --git a/bin/omarchy-update-mise b/bin/omarchy-update-mise index 78c25735..1e4fd4ec 100755 --- a/bin/omarchy-update-mise +++ b/bin/omarchy-update-mise @@ -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 diff --git a/install/user/mise.sh b/install/user/mise.sh index 89f1cfb5..1d85e2b3 100644 --- a/install/user/mise.sh +++ b/install/user/mise.sh @@ -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 diff --git a/manual/17-ai.md b/manual/17-ai.md index b00b6630..ea8a8d65 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -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 [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 ` 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. diff --git a/migrations/1790863209.sh b/migrations/1790863209.sh new file mode 100644 index 00000000..9559e735 --- /dev/null +++ b/migrations/1790863209.sh @@ -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 diff --git a/shell/Ui/PanelKeyCatcher.qml b/shell/Ui/PanelKeyCatcher.qml index b110b223..03cf59f1 100644 --- a/shell/Ui/PanelKeyCatcher.qml +++ b/shell/Ui/PanelKeyCatcher.qml @@ -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 } diff --git a/shell/plugins/agents/Main.qml b/shell/plugins/agents/Main.qml index fe92b319..865a1880 100644 --- a/shell/plugins/agents/Main.qml +++ b/shell/plugins/agents/Main.qml @@ -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) { diff --git a/shell/plugins/agents/Panel.qml b/shell/plugins/agents/Panel.qml index 019e7fec..d7e4333d 100644 --- a/shell/plugins/agents/Panel.qml +++ b/shell/plugins/agents/Panel.qml @@ -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. diff --git a/shell/plugins/agents/README.md b/shell/plugins/agents/README.md index aab61e45..c9daebba 100644 --- a/shell/plugins/agents/README.md +++ b/shell/plugins/agents/README.md @@ -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/.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 diff --git a/test/shell.d/agent-account-test.sh b/test/shell.d/agent-account-test.sh index 52c7d65e..19cefe59 100644 --- a/test/shell.d/agent-account-test.sh +++ b/test/shell.d/agent-account-test.sh @@ -192,13 +192,12 @@ OMARCHY_TEST_DEFAULT_AGENT="" omarchy-agent-account-add grok /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 [[ $(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 diff --git a/test/shell.d/agent-usage-grok-test.sh b/test/shell.d/agent-usage-grok-test.sh index 62242896..6a31d7e9 100755 --- a/test/shell.d/agent-usage-grok-test.sh +++ b/test/shell.d/agent-usage-grok-test.sh @@ -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" diff --git a/test/shell.d/agents-panel-test.sh b/test/shell.d/agents-panel-test.sh index 237230f9..b8e6d8d2 100755 --- a/test/shell.d/agents-panel-test.sh +++ b/test/shell.d/agents-panel-test.sh @@ -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 diff --git a/test/shell.d/default-agent-test.sh b/test/shell.d/default-agent-test.sh index 60e630d3..e70405ea 100644 --- a/test/shell.d/default-agent-test.sh +++ b/test/shell.d/default-agent-test.sh @@ -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 diff --git a/test/shell.d/update-mise-grok-test.sh b/test/shell.d/update-mise-grok-test.sh deleted file mode 100755 index 1c301d60..00000000 --- a/test/shell.d/update-mise-grok-test.sh +++ /dev/null @@ -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" <"$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"