Switch between several Claude and Codex subscriptions, and build apps the Omarchy way (#13770)
* Plan multiple Claude and Codex accounts with manual or automatic switching Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep several Claude and Codex accounts and start new sessions as the active one Each added account gets its own home holding only its login, with history, settings and skills linked back to ~/.claude or ~/.codex so --continue works across a switch. Accounts are added through the CLI's own login, and cx, cy, plain claude/codex and omarchy-agent all start as the active account unless CLAUDE_CONFIG_DIR or CODEX_HOME is already set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Report limits for every Claude and Codex account in the usage records Each registered account is probed with its own sign-in and cached on its own, and a parked account whose sign-in lapsed keeps its last-known numbers marked stale. The record's top-level limits keep describing the active account. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Switch accounts automatically near a limit, or notify with a one-click switch After each usage update, an active account at or over its provider's threshold (95% by default) moves new sessions to the account with the most headroom in auto mode, or offers that switch as a notification in manual mode. It never flaps back to an account that just reset, and says once when every account is over. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Show every subscription account's limits in the agents panel With several Claude or Codex accounts, the limits become one card per account with the active one badged. Pick a card with its number and press Enter (or click Use) to move new sessions to it, press a to add an account, and m to toggle automatic switching. Limits refresh every minute while an active account is above 80%. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Add Setup > Agent Accounts to list, switch and add Claude and Codex accounts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Document switching between several Claude and Codex subscriptions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Count a rested parked account as available, and pin Main to its own home A parked Claude account whose windows all reset now reads as 0% instead of unknown, so switching can pick it. Main's Codex limits come from ~/.codex even when CODEX_HOME is set, and duplicate logins are checked against who each home is signed in as now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Key limits caches by subscription and report when an account truly frees up An added account's limits cache follows its account id, so a new account reusing a removed one's label never inherits its allowance. The all-accounts notice now names when an account's blocking windows have all reset, not the earliest reset of any window. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep account ids clear of routing keywords and refresh the panel after removal Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Share Codex plugins and hooks across accounts, and key Claude caches by current sign-in Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drop the key hint from the Add account button Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Sign new agent accounts in through a private browser window The main browser is almost certainly signed in to the account you already have, and the login would silently reuse it. Both CLIs open their login page through $BROWSER, so it now points at omarchy-launch-browser --private. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Let agent account commands default to your default agent's provider With Claude or Codex as the default agent, the provider can be left out: omarchy agent account use work, and primary names the primary account. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drop the primary alias, which shadowed an account named Primary Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Replace the auto switch button with a small Notify / Autoswitch toggle Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Add accounts from a small + beside the switch toggle Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fix the punctuation of the switch toggle's README line Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drop the border around the add account + Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Rename Claude and Codex accounts without moving their homes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Lay out agent accounts on accent rails instead of boxed cards The active account gets an accent rail and the others a quiet one, each window is a single compact line, and the switch toggle, add, and Use are text. Clicking an account's name renames it in place. A window without a reset time no longer leaves an empty line, and last-known numbers are only red when the sign-in needs attention. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Read Codex limits without waiting on account/read Codex 0.158's app-server can leave account/read unanswered, and asking it first lost the limits whenever it did, leaving the agents panel showing "Codex limits unavailable". The limits name the plan themselves, so they're asked first and account/read is only a short fallback when they don't. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Switch agent providers from their marks in the panel header The row of provider buttons gives way to a small mark per provider in the hero's corner, the selected one at full strength, with the add account + beside them in place of the + by the switch toggle and the full-width Add account button. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep the agents panel's scrollbar off its contents The panel's content narrows to leave the scrollbar its own strip whenever it scrolls, and the add account + leads the provider marks instead of sitting at the very edge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Add Claude, Codex, or Grok subscriptions from one Add Account menu The first account for a provider installs its CLI if needed and signs in to the CLI's own home through the normal browser; only additional accounts get a home of their own and a private window. Grok signs in its first account. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Reveal Autoswitch beside Use instead of a Notify / Autoswitch row The panel's + now opens the Add Account menu for any provider. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Split agent accounts with plain separators instead of rails ACTIVE already marks the account new sessions use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Only call Claude limits stale once they're old, and poll near a limit less Anthropic rate-limits its usage endpoint, and polling two accounts every minute near a limit got every re-check refused, so both accounts read "Last known" with numbers a minute old. A refused check of numbers under 15 minutes old now counts as current, older ones say how old they are, and near-limit polling is every three minutes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Show every agent's limits on one page under an Agents hero The panel stops switching between one provider at a time and lists every agent and account with its limits, dropping the tokens-by-day and by-model charts. The hero carries the agents robot and rotates through what the token counts add up to: tokens this week and today, the most used model, the busiest day, and today's prompts and sessions. Middle-clicking the bar icon refreshes, since there's no provider left to advance to. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Report Codex's free rate-limit resets in its usage record Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Give the agents panel more room, and show Codex's free resets Limit lines are a notch larger with more space between them, each account's limits sit a clear step below its name, and sections breathe. Codex has one limit on Pro, so its section now also says how many free full resets are waiting and when the next one lapses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Mark Claude's Fable limit on the Weekly meter instead of its own row A model-scoped allowance on the same clock as a base window is drawn as a tick on that window's meter and named in the row's tooltip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Let omarchy-default-agent set the default without launching it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Let the agents panel drive adding an account without a terminal --check says whether adding one now would be each provider's first sign-in or an additional account, and --events reports progress as tagged lines alongside the CLI's own output, notifies with the result, and cleans up the login and its scratch home when cancelled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Add subscriptions and pick the default agent right in the agents panel The + is a proper accent button, and it swaps the list for a picker of Claude, Codex, and Grok that signs in without a terminal: name a further account, then follow the sign-in with its status, Grok's confirmation code, a field for Claude's pasted code, and a link to reopen the page. A dropdown at the bottom sets the default agent without launching it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Draw the Fable tick in its meter's own color Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Revert "Let omarchy-default-agent set the default without launching it" This reverts commit da96261f7d26bb76774cbdc1cb9e0abde92bb841. * Make the first agent signed in the default when none is picked Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep the agents panel in the bar, with setup, starters, and adding in it A machine with no agent opens the panel on setting one up. Once set up, the list ends with starter prompts for a new theme, plugin, or app, and a quiet Add a subscription. The panel no longer sets the default agent, and the hero drops its add button. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Offer the starter prompts as tiles, and adding as a row beneath them Theme, Plugin, and App each get a tile with its glyph, and Add a subscription a matching row with the + in a tinted square. The panel is allowed to grow tall enough to show it all without scrolling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Put the tinted add button in the agents panel's hero corner Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Show when a limit resets in small type under its meter The percentage keeps the right edge to itself, so the meter runs longer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Revert "Show when a limit resets in small type under its meter" This reverts commit 108d9de59da1af12cddbf232b0b5333ad8994c64. * Put each account's plan beside its name, and its email in a tooltip Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Stack each limit's percentage over its time left, so the meter runs longer Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Show only each limit's time left, with the exact percentage on hover The meter already says how full a window is. Dropping the percentage beside it leaves one calm figure per row, and the row's tooltip carries the number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Sign an existing account in again with omarchy-agent-account-add --reauth It signs in where the account already lives: the CLI's own home (:primary) through the normal browser, an added one through a private window. The usage records now say which account is the primary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Offer Sign-in required in the agents panel instead of how old the numbers are A lapsed sign-in shows as a link that signs that account in again right in the panel. The "as of" and "last known" notes are gone; trouble that isn't about signing in still shows as text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep the separator dot out of the Sign-in required link Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drop the Setup > Agent Accounts menu and the stale plan The agents panel now lists, switches, and adds accounts, so the menu's duplicate of it goes, and the plan written before the design settled no longer describes it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Install the compiler and Qt pieces for building Omarchy-style apps base-devel plus qt6-base, qt6-declarative, qt6-multimedia, and qt6-wayland, which is what Hype, Monologue, and Omacut build with through qmake6 and make. Qt was only there as a dependency of those apps, and the compiler not at all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Add an omarchy-app agent skill for building apps the Omarchy way It teaches how Hype, Monologue, and Omacut are built: C++ and Qt Quick in one flat project, qmake6 and make into a single binary, a theme that follows Omarchy's accent live, portal dialogs, keyboard-first conventions, Qt Test offscreen, and a PKGBUILD that puts the app in the launcher, with starter files that build and pass their tests as written. The agents panel's App starter uses it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Link the omarchy-app skill on existing installs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Let Cancel stop a waiting login, and list a lone account's limits Bash holds a trap until the foreground command finishes, so a login waiting on the browser outlived Cancel. It now runs behind an interruptible wait. With one account the usage record keeps its limits at the top level, which omarchy agent account list now reads for the primary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Re-read identity even when signed out, and count Grok as set up A home whose login is gone no longer keeps the identity the registry saved, so it reads as signed out and can be added again. The agents panel leaves its setup screen once any agent is signed in, not only once one has usage to show, since Grok has no usage collector. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Save each account's current identity before collecting usage A home signed in to someone else since it was added kept the old email in the registry, which the usage records name accounts by. The usage update now refreshes the registry from each home first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Offer Claude's paste field from the start, and keep providers with accounts Claude's login prompts for a pasted code without a newline, so the panel's line reader never saw it; the field is simply there for Claude sign-ins. A provider with accounts stays listed even when the active one's limits are unavailable, so its other accounts can still be switched to. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Never autoswitch to a signed-out account, and report Codex's missing sign-in An account nobody is signed in to is left out of switching, however much room its cached numbers show. Codex answers a home without a login with an error, which now reads as Waiting for auth, so the panel offers Sign-in required for Codex accounts too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Link shared files before they exist, and only call every account over when it is A shared file the primary home didn't have yet was left unlinked, so an added account made its own copy and the two diverged for good; it's linked up front now, and written in the primary home by whichever account writes it first. The all-accounts-over notice waits until every account's limits are actually known. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Make the agent starter tiles compact and call the section Make something cool Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hold the agents hero line until its next fade Every usage record that landed rebuilt the summary phrases, and the hero indexed that live list, so opening the panel could swap the line several times between fades. It now keeps what it shows until the timed swap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Start the default agent from the agents panel hero A console button beside the add button runs the same launcher as the right click: the default agent, or the picker when none is set. It hides while adding a subscription, where the add button becomes the way back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Put the add button before the agent launcher in the hero corner Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Bring keyboard navigation back to the agents panel The one-page redesign dropped left and right with the per-agent pages, leaving the arrows only to scroll. They now walk everything that does something, row by row: the hero's add and launch buttons, each switchable account, and the starter tiles. Enter acts on the cursor, the cursor scrolls into view, and hovering moves the same cursor so only one thing is lit. Number keys still jump to an account. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Let adding an account take over the agents hero While adding, the hero's line reads "Add an account" in place of the rotating summary, and the X in its corner is the only way back, so the add view drops its own title and Back link. Each agent to add is just its mark and name; one that can't be added is dimmed and says why on hover. The arrows walk that list too, and Enter picks one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Show the agents to add as large marks, three across Each agent is a large mark over its name with no box around it, in one row the arrows move along. The chosen one turns accent and grows a touch. The reason an agent can't be added is shortened to fit inside the panel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Move between Autoswitch and Use on an account with the arrows An account that isn't active is now two stops, Autoswitch then Use, so left and right move between them and Enter acts on the one that's lit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Land on Use when moving up or down onto an account Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hide Use on an autoswitching account until you're on its line With Autoswitch on, the line shows only Autoswitch. Use appears when the line is hovered anywhere or the keyboard is on it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Start adding an account with the first agent focused Opening the add screen puts the keyboard cursor on the first agent, so Enter picks it straight away. A focused agent lights up even before the check says whether it can be added. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drop the Sign in link under the account name field Enter in the field already starts the sign-in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Key the primary Claude account's limits cache by its subscription The primary home always used claude-limits.json, so signing it in to another subscription could carry the old one's numbers over when the first probe failed, and autoswitch would act on them. Once the home says who it's signed in as, its cache is keyed by that like every other account's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Leave Qt Multimedia out of the base packages again Quattro dropped it once the shell no longer needed it, and the app skill already has an app that plays audio or video add it and list it in its own depends. The compiler and the rest of Qt stay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Render the agents panel's dynamic text as plain text Sign-in status, help text, and provider names come from outside the shell, so none of them should be read as markup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep an account's plan and trouble clear of its Use and Autoswitch links The details beside an account's name grew to their full width, so a long plan or warning could run under the links on the right. They now shorten with an ellipsis instead, and Sign-in required keeps its whole width. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Let primary name the first account, as the manual says `omarchy agent account rename primary Hey` was documented but failed, since the primary account's id is main and lookups took exact ids only. primary now reaches the account marked primary, whatever it's been renamed to, and no new account can take primary as its id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Install everything an Omarchy app needs by default The app skill builds with Qt Multimedia, SVG icons, and ffmpeg as well as the compiler and the rest of Qt, so qt6-multimedia, qt6-svg, and ffmpeg join the base packages and the migration. The shell still plays no video through Qt Multimedia, which is what its test now checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Dim agent limits kept from an earlier check, with their age on hover When a Claude probe fails, the last numbers carry on and looked just like fresh ones. Those meters now dim, and their tooltip says how old they are. The record carries limitsStale and limitsFetchedAt for this. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Give the app skill templates for every file its build needs The templates named src/backend.{h,cpp} and a test file without showing them, so each app had to invent its own. They're now templated, with a starter icon, a note that LICENSE and the icon must exist for package(), and qt6-wayland in the PKGBUILD's depends. Scaffolded from the templates alone, the app builds and its tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Start the agents panel cursor over when the agent list comes or goes Right after the shell starts, the panel can briefly look like a first setup and focus the first agent to add. When the records land the rows change under the cursor, which then lit an account nobody had picked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drop dead hover and limit checks from the agents panel The tiles and hero buttons fell back on their own hover when there were no keyboard rows, but the hero always has one, so hover only ever moves the cursor. Stale meters only exist when there are limits, so neither the panel nor the record needs to check for some. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drop the a hotkey for adding an agent subscription The + in the hero is the way in, by mouse or by arrowing to it. The plugin README also catches up with the panel: the launcher, dimmed stale limits, the new add screen, and the arrows walking a cursor rather than scrolling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * List a command family that sits under a flags-only command `omarchy agent account` reached omarchy-agent, which takes only flags, and failed on the word instead of listing the account commands. When the command matched so far takes only flags and the next word names visible commands, the router now lists them. `omarchy update aur` likewise lists the update aur commands rather than running a full update. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Refuse to remove an agent account a running session uses Removing an account deleted its home at once, pulling the login out from under any session started in it, though running sessions are never meant to be touched. It now says to quit that session first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Prefer an account checked just now when switching near a limit Autoswitch weighed numbers kept from an earlier check like fresh ones. A parked account's sign-in lapses within hours, so a stale one stays a candidate, but an account checked just now wins over it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Leave a user's own omarchy-app skill in place when linking ours Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Reach Sign-in required with the keyboard in the agents panel Both the link on a lapsed account and the one on a single-account agent are now stops in the cursor's walk, and Enter signs in again. A picked link scrolls into view. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Bring the manual's agent accounts paragraph up to date Near-limit checks run every three minutes, not every minute, and Autoswitch now appears on hovering an account's line and takes Use's place while it's on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep the agents panel cursor on things you can act on The active account was a stop with nothing to do, so the cursor seemed to vanish there. It now only stops on it to sign in again. The hero's buttons also show the cursor plainly, with an accent border and a deeper tint instead of a shade's difference. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Never leave an added agent account's login outside the registry Registering moved the login into its home before carrying settings over and saving the registry, so a failure in either stranded a home holding a sign-in that the account commands couldn't see. Settings are now carried while the login is still pending, and a failed save moves the home back there for the add command to clean up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Put room before freshness when picking an account to switch to Preferring accounts checked just now outright could pick a fresh one at 94% over a stale one at 10%. An account within 15 points of the threshold now counts as near its limit however fresh, so accounts with room come first and freshness only decides among them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Check agent limits more often relative to the switch threshold Faster checks started at a fixed 80%, so a threshold set lower could be crossed and wait out the 15-minute interval. They now start 15 points below the threshold, which is still 80% at the default 95%. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8b4eae66da
commit
c05d90196f
32 files changed
+4646
-617
No files matched your search
+26
-1
@@ -26,7 +26,7 @@ declare -A ROUTE_IS_ALIAS
|
||||
declare -A BINARY_TO_KEY
|
||||
declare -A GROUP_DESCRIPTIONS
|
||||
|
||||
GROUP_DESCRIPTIONS[agent]="AI coding agent usage data"
|
||||
GROUP_DESCRIPTIONS[agent]="AI coding agents, their usage, and subscription accounts"
|
||||
GROUP_DESCRIPTIONS[ascii]="Text drawn as ASCII art"
|
||||
GROUP_DESCRIPTIONS[audio]="Audio input and output controls"
|
||||
GROUP_DESCRIPTIONS[bar]="Omarchy shell bar layout and settings"
|
||||
@@ -371,6 +371,20 @@ command_requires_args() {
|
||||
[[ -n $required ]]
|
||||
}
|
||||
|
||||
# A command whose documented arguments are all flags can't take a word, so a
|
||||
# word after it names something else: `omarchy agent account` is the account
|
||||
# commands, not an argument to omarchy-agent.
|
||||
command_takes_only_flags() {
|
||||
local key="$1"
|
||||
local args="${COMMAND_ARGS[$key]}"
|
||||
local token=""
|
||||
|
||||
[[ -n $args ]] || return 1
|
||||
for token in $args; do
|
||||
[[ $token == -* || $token == "[-"* ]] || return 1
|
||||
done
|
||||
}
|
||||
|
||||
load_group_commands() {
|
||||
local group="$1"
|
||||
local file=""
|
||||
@@ -956,6 +970,17 @@ dispatch_fast_or_help() {
|
||||
remaining=("${args[@]:DIRECT_RESOLVED_COUNT}")
|
||||
binary_path="$OMARCHY_BIN_DIR/$DIRECT_RESOLVED_BINARY"
|
||||
|
||||
if (( ${#remaining[@]} == 1 || ${#remaining[@]} == 2 )) && [[ ${remaining[0]} != -* ]] &&
|
||||
{ (( ${#remaining[@]} == 1 )) || remaining_has_help_flag "${remaining[1]}"; } &&
|
||||
group_has_child_commands "${DIRECT_RESOLVED_BINARY#omarchy-}-${remaining[0]}"; then
|
||||
load_command_by_binary "$DIRECT_RESOLVED_BINARY"
|
||||
key="${BINARY_TO_KEY[$DIRECT_RESOLVED_BINARY]}"
|
||||
if [[ -n $key ]] && command_takes_only_flags "$key"; then
|
||||
load_commands
|
||||
show_prefix_help "$DIRECT_RESOLVED_ROUTE ${remaining[0]}" && return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if remaining_has_help_flag "${remaining[@]}"; then
|
||||
if ! load_command_by_binary "$DIRECT_RESOLVED_BINARY"; then
|
||||
echo "Binary is missing or not executable: $DIRECT_RESOLVED_BINARY" >&2
|
||||
|
||||
@@ -136,6 +136,18 @@ pi)
|
||||
;;
|
||||
esac
|
||||
|
||||
# Claude and Codex start as the active subscription account, the same as the
|
||||
# shell functions in default/bash/fns/agent-accounts. The home travels in the
|
||||
# command itself, since a terminal launched through uwsm doesn't inherit this
|
||||
# process's environment.
|
||||
if [[ $agent == "claude" && -z ${CLAUDE_CONFIG_DIR:-} ]]; then
|
||||
home=$(omarchy-agent-account-home claude)
|
||||
[[ -n $home ]] && command=(env "CLAUDE_CONFIG_DIR=$home" "${command[@]}")
|
||||
elif [[ $agent == "codex" && -z ${CODEX_HOME:-} ]]; then
|
||||
home=$(omarchy-agent-account-home codex)
|
||||
[[ -n $home ]] && command=(env "CODEX_HOME=$home" "${command[@]}")
|
||||
fi
|
||||
|
||||
if [[ $inline == "true" ]]; then
|
||||
exec "${command[@]}"
|
||||
else
|
||||
|
||||
Executable
+240
@@ -0,0 +1,240 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Sign in to a Claude, Codex, or Grok subscription
|
||||
# omarchy:args=[--check] [--events] [--reauth <account>] [claude|codex|grok] [label]
|
||||
# omarchy:examples=omarchy agent account add claude | omarchy agent account add Work | omarchy agent account add codex Side
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# --check prints, per provider, whether adding one now would be its first
|
||||
# sign-in, an additional account, or not possible yet. --events is for the
|
||||
# agents panel: progress arrives as `@@omarchy <event> <detail>` lines mixed
|
||||
# into the CLI's own output (which carries codes the panel shows), nothing
|
||||
# waits on a terminal, and the result also lands as a notification in case
|
||||
# the panel has closed.
|
||||
# --reauth <account> signs an account that's already here in again, in its
|
||||
# own home, for when its sign-in has lapsed.
|
||||
check=false
|
||||
events=false
|
||||
reauth=""
|
||||
while [[ ${1:-} == --* ]]; do
|
||||
case "$1" in
|
||||
--check) check=true ;;
|
||||
--events) events=true ;;
|
||||
--reauth)
|
||||
reauth="${2:-}"
|
||||
shift
|
||||
;;
|
||||
*) break ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# Whether a provider's own home (~/.claude, ~/.codex, ~/.grok) is signed in.
|
||||
primary_signed_in() {
|
||||
if [[ $1 == "grok" ]]; then
|
||||
[[ -s $HOME/.grok/auth.json ]]
|
||||
else
|
||||
omarchy-agent-account-state list "$1" | jq -e '.[0].accounts | any(.primary and .signedIn)' >/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ $check == "true" ]]; then
|
||||
for p in claude codex grok; do
|
||||
if ! primary_signed_in "$p"; then
|
||||
echo "$p first"
|
||||
elif [[ $p == "grok" ]]; then
|
||||
echo "$p unsupported"
|
||||
else
|
||||
echo "$p additional"
|
||||
fi
|
||||
done
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# The provider can be left out when it's your default agent:
|
||||
# `omarchy agent account add Work`.
|
||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
|
||||
default=$(omarchy-default-agent)
|
||||
[[ $default == "claude" || $default == "codex" || $default == "grok" ]] && set -- "$default" "$@"
|
||||
fi
|
||||
|
||||
case "${1:-}" in
|
||||
claude) provider="claude"; name="Claude"; site="claude.ai"; package="claude" ;;
|
||||
codex) provider="codex"; name="Codex"; site="chatgpt.com"; package="codex" ;;
|
||||
grok) provider="grok"; name="Grok"; site="x.ai"; package="npm:@xai-official/grok" ;;
|
||||
*)
|
||||
echo "Usage: omarchy-agent-account-add [--check] [--events] [--reauth <account>] [claude|codex|grok] [label]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
label="${2:-}"
|
||||
|
||||
say() {
|
||||
if [[ $events == "true" ]]; then
|
||||
printf '@@omarchy status %s\n' "$*"
|
||||
else
|
||||
echo "$*"
|
||||
fi
|
||||
}
|
||||
|
||||
finish() {
|
||||
if [[ $events == "true" ]]; then
|
||||
printf '@@omarchy done %s\n' "$1"
|
||||
omarchy-notification-send -g "$1"
|
||||
else
|
||||
echo
|
||||
echo "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
fail() {
|
||||
if [[ $events == "true" ]]; then
|
||||
printf '@@omarchy error %s\n' "$1"
|
||||
omarchy-notification-send -g -u critical "Couldn't add the $name account" "$1"
|
||||
else
|
||||
echo "$1" >&2
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Cancelling from the panel stops the CLI's login too, and leaves no
|
||||
# half-made home behind.
|
||||
home=""
|
||||
browser=""
|
||||
cleanup() {
|
||||
[[ -n $browser ]] && rm -f "$browser"
|
||||
[[ -n $home && -d $home && $home == */.pending/* ]] && rm -rf "$home"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'pkill -TERM -P $$; exit 130' TERM INT
|
||||
|
||||
# Bash holds a trap until a foreground command finishes, and a login waits on
|
||||
# the browser for as long as it takes. Running it in the background behind a
|
||||
# `wait` lets Cancel stop it at once. It keeps this script's stdin, where the
|
||||
# panel sends a pasted code.
|
||||
interruptible() {
|
||||
"$@" <&0 &
|
||||
wait $!
|
||||
}
|
||||
|
||||
if omarchy-cmd-missing "$provider"; then
|
||||
say "Installing the $name CLI…"
|
||||
if [[ $events == "true" ]]; then
|
||||
mise use -g "$package" >/dev/null 2>&1 || fail "Installing the $name CLI didn't finish."
|
||||
else
|
||||
mise use -g "$package" || exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Your main browser is almost certainly signed in to the account you already
|
||||
# have, and a login would quietly reuse it. The CLIs open their login page
|
||||
# through $BROWSER, so this points it at a private window instead.
|
||||
private_browser() {
|
||||
browser=$(mktemp "${XDG_RUNTIME_DIR:-/tmp}/omarchy-account-browser.XXXXXX")
|
||||
printf '#!/bin/bash\nexec omarchy-launch-browser --private "$1"\n' >"$browser"
|
||||
chmod +x "$browser"
|
||||
}
|
||||
|
||||
case "$provider" in
|
||||
claude) login_command=(claude auth login) ;;
|
||||
codex) login_command=(codex login) ;;
|
||||
grok) login_command=(grok login) ;;
|
||||
esac
|
||||
|
||||
# The CLI's own home, whatever this shell has pointed it at.
|
||||
login() {
|
||||
interruptible env -u CLAUDE_CONFIG_DIR -u CODEX_HOME -u GROK_HOME "${login_command[@]}"
|
||||
}
|
||||
|
||||
# Signing an account that's already here in again, where it lives: the
|
||||
# primary home through the normal browser, an added one through a private
|
||||
# window. Its home is never recreated or removed.
|
||||
if [[ -n $reauth ]]; then
|
||||
if [[ $provider == "grok" || $reauth == ":primary" ]]; then
|
||||
account_home="$HOME/.$provider"
|
||||
else
|
||||
account_home=$(omarchy-agent-account-state homes "$provider" | awk -F'\t' -v id="$reauth" '$1 == id { print $2 }')
|
||||
fi
|
||||
[[ -n $account_home ]] || fail "No $name account named $reauth."
|
||||
|
||||
if [[ $account_home == "$HOME/.$provider" ]]; then
|
||||
say "Sign in to $name again in the browser window that opens."
|
||||
login || fail "The $name sign-in didn't finish."
|
||||
else
|
||||
say "Sign in again as this account in the private window that opens."
|
||||
private_browser
|
||||
if [[ $provider == "claude" ]]; then
|
||||
interruptible env BROWSER="$browser" CLAUDE_CONFIG_DIR="$account_home" claude auth login || fail "The $name sign-in didn't finish."
|
||||
else
|
||||
interruptible env BROWSER="$browser" CODEX_HOME="$account_home" codex login || fail "The $name sign-in didn't finish."
|
||||
fi
|
||||
fi
|
||||
|
||||
omarchy-agent-usage-update --force "$provider" >/dev/null 2>&1 &
|
||||
finish "Signed in to $name again."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# The first subscription signs in where the CLI always looks, through your
|
||||
# normal browser: nothing is signed in there yet that it could be mistaken for.
|
||||
if ! primary_signed_in "$provider"; then
|
||||
say "Sign in to $name in the browser window that opens."
|
||||
if login && primary_signed_in "$provider"; then
|
||||
# The first agent signed in on a machine with none chosen becomes the
|
||||
# default, so `omarchy agent` and the panel's starter prompts have one to
|
||||
# run. A default someone already picked is never touched.
|
||||
if [[ -z $(omarchy-default-agent) ]]; then
|
||||
mkdir -p "$HOME/.config/omarchy/defaults"
|
||||
printf '%s\n' "$provider" >"$HOME/.config/omarchy/defaults/agent"
|
||||
fi
|
||||
omarchy-agent-usage-update --force "$provider" >/dev/null 2>&1 &
|
||||
finish "Signed in to $name."
|
||||
else
|
||||
fail "The $name sign-in didn't finish."
|
||||
fi
|
||||
elif [[ $provider == "grok" ]]; then
|
||||
fail "Grok is already signed in, and a second Grok account isn't supported yet."
|
||||
else
|
||||
if [[ -z $label && $events == "false" && -t 0 ]]; then
|
||||
label=$(gum input --placeholder "Work" --prompt "Name this $name account: ") || exit 130
|
||||
fi
|
||||
|
||||
if [[ $events == "true" ]]; then
|
||||
say "Sign in as the account you're adding in the private window that opens."
|
||||
else
|
||||
cat <<EOF
|
||||
|
||||
Sign in as the account you're adding in the private window that opens.
|
||||
If a private window was already open, close it first, or $site may reuse whoever it has signed in.
|
||||
|
||||
Don't log out of the $name CLI to do this: logging out revokes the saved sign-in.
|
||||
|
||||
EOF
|
||||
fi
|
||||
|
||||
home=$(omarchy-agent-account-state begin "$provider") || fail "Couldn't set up a home for the new account."
|
||||
|
||||
private_browser
|
||||
|
||||
if [[ $provider == "claude" ]]; then
|
||||
interruptible env BROWSER="$browser" CLAUDE_CONFIG_DIR="$home" claude auth login
|
||||
else
|
||||
interruptible env BROWSER="$browser" CODEX_HOME="$home" codex login
|
||||
fi
|
||||
|
||||
if account=$(omarchy-agent-account-state register "$provider" "$label" "$home" 2>&1); then
|
||||
home=""
|
||||
added=$(jq -r '.label + (if .email != "" then " (" + .email + ")" else "" end)' <<<"$account")
|
||||
omarchy-agent-usage-update --force "$provider" >/dev/null 2>&1 &
|
||||
if [[ $events == "true" ]]; then
|
||||
finish "Added $added."
|
||||
else
|
||||
finish "Added $added."
|
||||
echo "Switch to it with: omarchy agent account use $provider $(jq -r .id <<<"$account")"
|
||||
fi
|
||||
else
|
||||
home=""
|
||||
fail "$account"
|
||||
fi
|
||||
fi
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Print the config home of the active Claude or Codex account
|
||||
# omarchy:args=<claude|codex>
|
||||
# omarchy:hidden=true
|
||||
|
||||
# Prints nothing while the primary account (~/.claude, ~/.codex) is active, so
|
||||
# a launch only sets CLAUDE_CONFIG_DIR / CODEX_HOME when there's somewhere
|
||||
# else to point it. Without a registry, no Python starts at all: this runs on
|
||||
# every `claude` and `codex` typed at a prompt.
|
||||
registry="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/agents/accounts/${1:-}.json"
|
||||
|
||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" ]]; then
|
||||
echo "Usage: omarchy-agent-account-home <claude|codex>" >&2
|
||||
exit 1
|
||||
elif [[ -f $registry ]]; then
|
||||
exec omarchy-agent-account-state home "$1"
|
||||
fi
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=List Claude and Codex subscription accounts and their limits
|
||||
# omarchy:args=[claude|codex] [--json]
|
||||
# omarchy:examples=omarchy agent account list | omarchy agent account list claude --json
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
provider=""
|
||||
json=false
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
claude | codex) provider="$arg" ;;
|
||||
--json) json=true ;;
|
||||
*)
|
||||
echo "Usage: omarchy-agent-account-list [claude|codex] [--json]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
usage_dir="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/agents/usage"
|
||||
accounts=$(omarchy-agent-account-state list $provider) || exit 1
|
||||
|
||||
# Join each account with the limits its provider's usage record last saw. With
|
||||
# a single account the collectors keep its limits at the record's top level.
|
||||
accounts=$(jq -c --arg dir "$usage_dir" '
|
||||
map(. as $p | .accounts |= map(. + {limits: []}))' <<<"$accounts")
|
||||
for p in claude codex; do
|
||||
record="$usage_dir/$p.json"
|
||||
[[ -f $record ]] || continue
|
||||
accounts=$(jq -c --arg p "$p" --slurpfile record "$record" '
|
||||
map(if .provider == $p then
|
||||
.accounts |= map(. as $a | . + {limits: (
|
||||
if ($record[0].accounts // []) | length > 0 then
|
||||
[$record[0].accounts[] | select(.id == $a.id) | .limits] | first // []
|
||||
elif $a.primary then
|
||||
$record[0].limits // []
|
||||
else
|
||||
[]
|
||||
end)})
|
||||
else . end)' <<<"$accounts")
|
||||
done
|
||||
|
||||
if [[ $json == "true" ]]; then
|
||||
echo "$accounts"
|
||||
else
|
||||
# A provider nobody has signed in to has nothing to list.
|
||||
for (( i = 0; i < $(jq length <<<"$accounts"); i++ )); do
|
||||
jq -e ".[$i].accounts | any(.signedIn)" <<<"$accounts" >/dev/null || continue
|
||||
jq -r ".[$i] | \"\(.name) — \(.switch) switching at \(.threshold)%\"" <<<"$accounts"
|
||||
jq -r ".[$i].accounts[] |
|
||||
\"\(if .active then \"*\" else \" \" end)\t\(.id)\t\(.label)\t\(.plan)\t\(.email)\t\" +
|
||||
([.limits[]? | select(.percent != null) | \"\(.label): \((.percent * 100) | round)%\"] | join(\", \"))" <<<"$accounts" |
|
||||
column -t -s $'\t' | sed 's/^/ /'
|
||||
echo
|
||||
done
|
||||
fi
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Switch Claude or Codex accounts automatically near a limit, or only notify
|
||||
# omarchy:args=[claude|codex] [manual|auto] [threshold]
|
||||
# omarchy:examples=omarchy agent account mode auto | omarchy agent account mode auto 90 | omarchy agent account mode codex manual
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# The provider can be left out when it's your default agent:
|
||||
# `omarchy agent account use work`.
|
||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" ]]; then
|
||||
default=$(omarchy-default-agent)
|
||||
[[ $default == "claude" || $default == "codex" ]] && set -- "$default" "$@"
|
||||
fi
|
||||
|
||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" ]]; then
|
||||
echo "Usage: omarchy-agent-account-mode [claude|codex] [manual|auto] [threshold]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n ${2:-} ]]; then
|
||||
omarchy-agent-account-state mode "$@" || exit 1
|
||||
fi
|
||||
|
||||
omarchy-agent-account-state registry "$1" | jq -r '
|
||||
if .switch == "auto" then
|
||||
"\(.name) switches new sessions to the account with the most headroom at \(.threshold)%."
|
||||
else
|
||||
"\(.name) notifies at \(.threshold)% and switches when you say so."
|
||||
end'
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Forget an added Claude or Codex account
|
||||
# omarchy:args=[claude|codex] <account>
|
||||
# omarchy:examples=omarchy agent account remove work | omarchy agent account remove codex side
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# The provider can be left out when it's your default agent:
|
||||
# `omarchy agent account use work`.
|
||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" ]]; then
|
||||
default=$(omarchy-default-agent)
|
||||
[[ $default == "claude" || $default == "codex" ]] && set -- "$default" "$@"
|
||||
fi
|
||||
|
||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" ]] || [[ -z ${2:-} ]]; then
|
||||
echo "Usage: omarchy-agent-account-remove [claude|codex] <account>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Removing only deletes the account's home here. It never logs out, which
|
||||
# would revoke the sign-in for every other copy of it too.
|
||||
if [[ -t 0 ]] && ! gum confirm "Forget the $1 account $2? You'd have to sign in again to add it back."; then
|
||||
exit 130
|
||||
fi
|
||||
|
||||
account=$(omarchy-agent-account-state remove "$1" "$2") || exit 1
|
||||
echo "Forgot $(jq -r .label <<<"$account")."
|
||||
# The panel reads which accounts exist from the usage record.
|
||||
omarchy-agent-usage-update --limits-only "$1" >/dev/null 2>&1 &
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Rename a Claude or Codex subscription account
|
||||
# omarchy:args=[claude|codex] <account> <name>
|
||||
# omarchy:examples=omarchy agent account rename primary Hey | omarchy agent account rename codex side Side project
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# The provider can be left out when it's your default agent:
|
||||
# `omarchy agent account rename primary Hey`.
|
||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" ]]; then
|
||||
default=$(omarchy-default-agent)
|
||||
[[ $default == "claude" || $default == "codex" ]] && set -- "$default" "$@"
|
||||
fi
|
||||
|
||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" ]] || (( $# < 3 )); then
|
||||
echo "Usage: omarchy-agent-account-rename [claude|codex] <account> <name>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
account=$(omarchy-agent-account-state rename "$@") || exit 1
|
||||
echo "Renamed to $(jq -r .label <<<"$account"). Switch to it with: omarchy agent account use $(jq -r .id <<<"$account")"
|
||||
# The panel reads account names from the usage record.
|
||||
omarchy-agent-usage-update --limits-only "$1" >/dev/null 2>&1 &
|
||||
Executable
+758
@@ -0,0 +1,758 @@
|
||||
#!/usr/bin/python3
|
||||
# omarchy:summary=Read and change the registry of Claude and Codex subscription accounts
|
||||
# omarchy:args=<registry|home|homes|list|refresh|begin|register|use|rename|remove|mode|link|autoswitch> [provider] [args...]
|
||||
# omarchy:hidden=true
|
||||
"""The account registry behind `omarchy agent account`.
|
||||
|
||||
An account is a Claude Code or Codex login Omarchy keeps a home for. The
|
||||
login you already have stays where the CLI put it (~/.claude, ~/.codex) and
|
||||
is the primary account; added accounts get a home of their own under
|
||||
~/.local/state/omarchy/agents/accounts/<provider>/<id>/ that holds only that
|
||||
account's credentials and links everything else back to the primary home, so
|
||||
conversation history, settings and skills stay one set across accounts.
|
||||
|
||||
Each provider's registry is one JSON file beside those homes. It records which
|
||||
account is active — the one new sessions start as — how switching happens, and
|
||||
display-safe identity. Tokens never enter it and never leave this process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime as dt
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
PROVIDERS = {
|
||||
"claude": {
|
||||
"name": "Claude",
|
||||
"home_env": "CLAUDE_CONFIG_DIR",
|
||||
# Everything a session reads or writes that isn't tied to the login. A
|
||||
# missing directory is created in the primary home first; a missing file
|
||||
# is linked anyway, so whichever account writes it first writes it there.
|
||||
"shared_dirs": ["projects", "skills", "agents", "commands", "plugins", "hooks", "themes", "output-styles", "file-history", "todos", "plans"],
|
||||
"shared_files": ["settings.json", "CLAUDE.md", "history.jsonl", "keybindings.json"],
|
||||
# Keys copied from the primary's ~/.claude.json into a new account's, so a
|
||||
# second login keeps MCP servers, folder trust and onboarding choices.
|
||||
"carried_keys": ["mcpServers", "projects", "theme", "hasCompletedOnboarding", "lastOnboardingVersion", "editorMode"],
|
||||
},
|
||||
"codex": {
|
||||
"name": "Codex",
|
||||
"home_env": "CODEX_HOME",
|
||||
"shared_dirs": ["sessions", "archived_sessions", "prompts", "skills", "rules", "plugins"],
|
||||
"shared_files": ["config.toml", "AGENTS.md", "history.jsonl", "hooks.json"],
|
||||
"carried_keys": [],
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_THRESHOLD = 95
|
||||
# Ids an account can't take because something else already answers to them:
|
||||
# `use next` cycles, a provider name is read as the provider when it comes
|
||||
# first, and the menu's Add Account row is `.add`.
|
||||
RESERVED_IDS = {"next", "add", "claude", "codex", "primary"}
|
||||
SWITCH_MODES = ("manual", "auto")
|
||||
|
||||
|
||||
class AccountError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- paths
|
||||
|
||||
|
||||
def state_root() -> Path:
|
||||
return Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state") / "omarchy" / "agents"
|
||||
|
||||
|
||||
def accounts_root() -> Path:
|
||||
return state_root() / "accounts"
|
||||
|
||||
|
||||
def registry_path(provider: str) -> Path:
|
||||
return accounts_root() / f"{provider}.json"
|
||||
|
||||
|
||||
def usage_record_path(provider: str) -> Path:
|
||||
return state_root() / "usage" / f"{provider}.json"
|
||||
|
||||
|
||||
# The primary home is where the CLI keeps a login when nothing tells it
|
||||
# otherwise. It is deliberately not read from CLAUDE_CONFIG_DIR / CODEX_HOME:
|
||||
# those are how an added account is selected, so honoring them here would make
|
||||
# whichever account launched this command look like the primary.
|
||||
def primary_home(provider: str) -> Path:
|
||||
return Path.home() / (".claude" if provider == "claude" else ".codex")
|
||||
|
||||
|
||||
def account_home(provider: str, account: dict[str, Any]) -> Path:
|
||||
home = str(account.get("home") or "")
|
||||
return Path(home) if home else primary_home(provider)
|
||||
|
||||
|
||||
# Claude keeps its account file beside the config dir for the default home
|
||||
# (~/.claude.json) and inside it for any other.
|
||||
def claude_account_file(home: Path) -> Path:
|
||||
if home == primary_home("claude"):
|
||||
return Path.home() / ".claude.json"
|
||||
return home / ".claude.json"
|
||||
|
||||
|
||||
# --------------------------------------------------------------- identity
|
||||
|
||||
|
||||
def read_json(path: Path) -> Any:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def plan_label(tier: str, subscription: str) -> str:
|
||||
match = re.search(r"max_(\d+x)", tier or "", re.IGNORECASE)
|
||||
if match:
|
||||
return "Max " + match.group(1)
|
||||
if subscription:
|
||||
return subscription[0].upper() + subscription[1:]
|
||||
return ""
|
||||
|
||||
|
||||
def jwt_claims(token: str) -> dict[str, Any]:
|
||||
try:
|
||||
payload = token.split(".")[1]
|
||||
payload += "=" * (-len(payload) % 4)
|
||||
claims = json.loads(base64.urlsafe_b64decode(payload.encode("ascii")))
|
||||
return claims if isinstance(claims, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
# Who a home is signed in as, from files the CLI wrote at login. Only
|
||||
# display-safe fields come out; an empty accountId means nobody is signed in.
|
||||
def identity(provider: str, home: Path) -> dict[str, str]:
|
||||
if provider == "claude":
|
||||
account = (read_json(claude_account_file(home)) or {}).get("oauthAccount") or {}
|
||||
login = (read_json(home / ".credentials.json") or {}).get("claudeAiOauth") or {}
|
||||
return {
|
||||
"accountId": str(account.get("accountUuid") or ""),
|
||||
"email": str(account.get("emailAddress") or ""),
|
||||
"org": str(account.get("organizationName") or ""),
|
||||
"plan": plan_label(str(login.get("rateLimitTier") or ""), str(login.get("subscriptionType") or "")),
|
||||
}
|
||||
|
||||
auth = read_json(home / "auth.json") or {}
|
||||
tokens = auth.get("tokens") or {}
|
||||
claims = jwt_claims(str(tokens.get("id_token") or ""))
|
||||
openai = claims.get("https://api.openai.com/auth") or {}
|
||||
plan = str(openai.get("chatgpt_plan_type") or "")
|
||||
return {
|
||||
"accountId": str(tokens.get("account_id") or openai.get("chatgpt_account_id") or ""),
|
||||
"email": str(claims.get("email") or ""),
|
||||
"org": "",
|
||||
"plan": plan[0].upper() + plan[1:] if plan else "",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- registry
|
||||
|
||||
|
||||
def slug(label: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "-", label.lower()).strip("-")
|
||||
|
||||
|
||||
def empty_registry(provider: str) -> dict[str, Any]:
|
||||
return {"active": "main", "switch": "manual", "threshold": DEFAULT_THRESHOLD, "alert": "", "accounts": []}
|
||||
|
||||
|
||||
def primary_entry(provider: str) -> dict[str, Any]:
|
||||
entry = {"id": "main", "label": "Main", "home": "", "primary": True}
|
||||
entry.update(identity(provider, primary_home(provider)))
|
||||
return entry
|
||||
|
||||
|
||||
def normalized(provider: str, data: Any) -> dict[str, Any]:
|
||||
registry = empty_registry(provider)
|
||||
if isinstance(data, dict):
|
||||
registry.update({key: data[key] for key in registry if key in data})
|
||||
accounts = [a for a in registry["accounts"] if isinstance(a, dict) and a.get("id")]
|
||||
if not any(a.get("primary") for a in accounts):
|
||||
accounts.insert(0, primary_entry(provider))
|
||||
registry["accounts"] = accounts
|
||||
if registry["switch"] not in SWITCH_MODES:
|
||||
registry["switch"] = "manual"
|
||||
try:
|
||||
registry["threshold"] = max(50, min(100, int(registry["threshold"])))
|
||||
except Exception:
|
||||
registry["threshold"] = DEFAULT_THRESHOLD
|
||||
if not find(registry, registry["active"]):
|
||||
registry["active"] = accounts[0]["id"]
|
||||
return registry
|
||||
|
||||
|
||||
def load(provider: str) -> dict[str, Any]:
|
||||
return normalized(provider, read_json(registry_path(provider)))
|
||||
|
||||
|
||||
def save(provider: str, registry: dict[str, Any]) -> None:
|
||||
path = registry_path(provider)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(path.parent, 0o700)
|
||||
fd, tmp = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent))
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(registry, handle, indent=2)
|
||||
handle.write("\n")
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, path)
|
||||
except Exception:
|
||||
Path(tmp).unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
# Every change is read-modify-write under one lock per provider, so the panel,
|
||||
# the autoswitch run after each usage update, and a command typed at a prompt
|
||||
# can't interleave and drop each other's edits.
|
||||
class Locked:
|
||||
def __init__(self, provider: str):
|
||||
self.provider = provider
|
||||
|
||||
def __enter__(self) -> dict[str, Any]:
|
||||
accounts_root().mkdir(parents=True, exist_ok=True)
|
||||
self.handle = open(accounts_root() / f".{self.provider}.lock", "w")
|
||||
fcntl.flock(self.handle, fcntl.LOCK_EX)
|
||||
self.registry = load(self.provider)
|
||||
return self.registry
|
||||
|
||||
def __exit__(self, kind, value, traceback) -> None:
|
||||
try:
|
||||
if kind is None:
|
||||
save(self.provider, self.registry)
|
||||
finally:
|
||||
fcntl.flock(self.handle, fcntl.LOCK_UN)
|
||||
self.handle.close()
|
||||
|
||||
|
||||
def find(registry: dict[str, Any], account_id: str) -> dict[str, Any] | None:
|
||||
for account in registry["accounts"]:
|
||||
if account.get("id") == account_id:
|
||||
return account
|
||||
# `primary` names the login that was there first, whatever it's called now.
|
||||
if account_id == "primary":
|
||||
for account in registry["accounts"]:
|
||||
if account.get("primary"):
|
||||
return account
|
||||
return None
|
||||
|
||||
|
||||
def active(registry: dict[str, Any]) -> dict[str, Any]:
|
||||
return find(registry, registry["active"]) or registry["accounts"][0]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- homes
|
||||
|
||||
|
||||
# Lay the links that make an added account share everything but its login
|
||||
# with the primary home. Safe to repeat: an existing link or real file is left
|
||||
# as it is, so nothing an account wrote for itself is ever replaced.
|
||||
def link_shared(provider: str, home: Path) -> None:
|
||||
spec = PROVIDERS[provider]
|
||||
primary = primary_home(provider)
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(home, 0o700)
|
||||
for name in spec["shared_dirs"]:
|
||||
(primary / name).mkdir(parents=True, exist_ok=True)
|
||||
for name in spec["shared_dirs"] + spec["shared_files"]:
|
||||
target = home / name
|
||||
if target.exists() or target.is_symlink():
|
||||
continue
|
||||
target.symlink_to(primary / name)
|
||||
|
||||
|
||||
def carry_settings(provider: str, home: Path) -> None:
|
||||
keys = PROVIDERS[provider]["carried_keys"]
|
||||
if not keys:
|
||||
return
|
||||
source = read_json(claude_account_file(primary_home(provider))) or {}
|
||||
path = claude_account_file(home)
|
||||
data = read_json(path) or {}
|
||||
for key in keys:
|
||||
if key in source and key not in data:
|
||||
data[key] = source[key]
|
||||
fd, tmp = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent))
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, indent=2)
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
# A login in progress gets a scratch home beside the real ones, so an
|
||||
# abandoned or duplicate login never shows up as an account.
|
||||
def begin(provider: str) -> Path:
|
||||
pending = accounts_root() / provider / ".pending"
|
||||
pending.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(pending.parent, 0o700)
|
||||
home = Path(tempfile.mkdtemp(prefix="login-", dir=str(pending)))
|
||||
link_shared(provider, home)
|
||||
return home
|
||||
|
||||
|
||||
def register(provider: str, label: str, pending: Path) -> dict[str, Any]:
|
||||
pending = pending.resolve()
|
||||
if pending.parent != (accounts_root() / provider / ".pending").resolve():
|
||||
raise AccountError(f"{pending} is not a pending {PROVIDERS[provider]['name']} login")
|
||||
|
||||
found = identity(provider, pending)
|
||||
if not found["accountId"]:
|
||||
shutil.rmtree(pending, ignore_errors=True)
|
||||
raise AccountError("The login didn't finish, so no account was added.")
|
||||
|
||||
with Locked(provider) as registry:
|
||||
# Each home is asked who it is now, not trusted from when it was added: a
|
||||
# `claude auth login` run by hand in ~/.claude changes the primary.
|
||||
for account in registry["accounts"]:
|
||||
account.update(identity(provider, account_home(provider, account)))
|
||||
for account in registry["accounts"]:
|
||||
if account.get("accountId") == found["accountId"]:
|
||||
shutil.rmtree(pending, ignore_errors=True)
|
||||
raise AccountError(
|
||||
f"That's {account['label']} ({found['email'] or 'already added'}). "
|
||||
"Try again, and sign in as the other account in the private window."
|
||||
)
|
||||
|
||||
label = label.strip() or found["email"].split("@")[0] or "Account"
|
||||
account_id = unique_id(provider, registry, label)
|
||||
|
||||
# Everything that can fail happens while the login is still pending, where
|
||||
# the add command cleans it up, or is undone: a home that isn't in the
|
||||
# registry would keep a sign-in nothing can manage.
|
||||
carry_settings(provider, pending)
|
||||
home = accounts_root() / provider / account_id
|
||||
pending.rename(home)
|
||||
entry = {"id": account_id, "label": label, "home": str(home), "primary": False}
|
||||
entry.update(found)
|
||||
registry["accounts"].append(entry)
|
||||
try:
|
||||
save(provider, registry)
|
||||
except Exception:
|
||||
registry["accounts"].pop()
|
||||
home.rename(pending)
|
||||
raise
|
||||
return entry
|
||||
|
||||
|
||||
# The id an account answers to, from its label. It can't clash with another
|
||||
# account, a routing keyword, or a home left under that name by an account
|
||||
# that has since been renamed.
|
||||
def unique_id(provider: str, registry: dict[str, Any], label: str, keep: dict[str, Any] | None = None) -> str:
|
||||
base = slug(label) or "account"
|
||||
account_id, n = base, 2
|
||||
while True:
|
||||
taken = find(registry, account_id)
|
||||
if account_id not in RESERVED_IDS and (taken is None or taken is keep):
|
||||
if keep is not None or not (accounts_root() / provider / account_id).exists():
|
||||
return account_id
|
||||
account_id, n = f"{base}-{n}", n + 1
|
||||
|
||||
|
||||
# A new label, and the id that follows from it. The home stays where it is:
|
||||
# sessions already running in it hold that path.
|
||||
def rename(provider: str, target: str, label: str) -> dict[str, Any]:
|
||||
label = label.strip()
|
||||
if not label:
|
||||
raise AccountError("Give the account a name")
|
||||
with Locked(provider) as registry:
|
||||
account = find(registry, target)
|
||||
if not account:
|
||||
raise AccountError(f"No {PROVIDERS[provider]['name']} account named {target}")
|
||||
new_id = unique_id(provider, registry, label, keep=account)
|
||||
if not account.get("primary") and not account.get("home"):
|
||||
account["home"] = str(account_home(provider, account))
|
||||
if registry["active"] == account["id"]:
|
||||
registry["active"] = new_id
|
||||
registry["alert"] = ""
|
||||
account["id"] = new_id
|
||||
account["label"] = label
|
||||
return dict(account)
|
||||
|
||||
|
||||
# Save who each home is signed in as now. A login run in a home, through the
|
||||
# panel or the CLI, changes it, and the usage records take their names and
|
||||
# emails from here.
|
||||
def refresh(provider: str) -> None:
|
||||
with Locked(provider) as registry:
|
||||
for account in registry["accounts"]:
|
||||
account.update(identity(provider, account_home(provider, account)))
|
||||
|
||||
|
||||
def use(provider: str, target: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
with Locked(provider) as registry:
|
||||
previous = active(registry)
|
||||
if target == "next":
|
||||
ids = [a["id"] for a in registry["accounts"]]
|
||||
target = ids[(ids.index(previous["id"]) + 1) % len(ids)]
|
||||
account = find(registry, target)
|
||||
if not account:
|
||||
raise AccountError(f"No {PROVIDERS[provider]['name']} account named {target}")
|
||||
registry["active"] = account["id"]
|
||||
registry["alert"] = ""
|
||||
if not account.get("primary"):
|
||||
link_shared(provider, account_home(provider, account))
|
||||
return previous, account
|
||||
|
||||
|
||||
# Whether a running session was started in this home. Removing it would pull
|
||||
# the login out from under that session, which must never be touched.
|
||||
def home_in_use(provider: str, home: Path) -> bool:
|
||||
wanted = f"{PROVIDERS[provider]['home_env']}=".encode()
|
||||
resolved = home.resolve()
|
||||
for environ in Path("/proc").glob("[0-9]*/environ"):
|
||||
try:
|
||||
entries = environ.read_bytes().split(b"\0")
|
||||
except OSError:
|
||||
continue
|
||||
for entry in entries:
|
||||
if entry.startswith(wanted) and Path(entry[len(wanted):].decode(errors="replace")).resolve() == resolved:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def remove(provider: str, target: str) -> dict[str, Any]:
|
||||
with Locked(provider) as registry:
|
||||
account = find(registry, target)
|
||||
if not account:
|
||||
raise AccountError(f"No {PROVIDERS[provider]['name']} account named {target}")
|
||||
if account.get("primary"):
|
||||
raise AccountError(f"{account['label']} is the login in {primary_home(provider)}; it can't be removed here.")
|
||||
home = account_home(provider, account)
|
||||
if home_in_use(provider, home):
|
||||
raise AccountError(f"A running {PROVIDERS[provider]['name']} session uses {account['label']}. Quit it first, then remove the account.")
|
||||
# Only the account's own directory goes. Its links point into the primary
|
||||
# home, and rmtree removes a link without following it.
|
||||
if home.resolve().parent == (accounts_root() / provider).resolve():
|
||||
shutil.rmtree(home, ignore_errors=True)
|
||||
registry["accounts"] = [a for a in registry["accounts"] if a["id"] != account["id"]]
|
||||
if registry["active"] == account["id"]:
|
||||
registry["active"] = registry["accounts"][0]["id"]
|
||||
return account
|
||||
|
||||
|
||||
def set_mode(provider: str, mode: str, threshold: str | None) -> dict[str, Any]:
|
||||
if mode not in SWITCH_MODES:
|
||||
raise AccountError("Switch mode is manual or auto")
|
||||
with Locked(provider) as registry:
|
||||
registry["switch"] = mode
|
||||
if threshold is not None:
|
||||
try:
|
||||
registry["threshold"] = max(50, min(100, int(threshold)))
|
||||
except ValueError:
|
||||
raise AccountError("The threshold is a percentage between 50 and 100")
|
||||
registry["alert"] = ""
|
||||
return dict(registry)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- autoswitch
|
||||
|
||||
|
||||
def window_open(limit: dict[str, Any], now: dt.datetime) -> bool:
|
||||
raw = str(limit.get("resetsAt") or "")
|
||||
if not raw:
|
||||
return True
|
||||
try:
|
||||
resets_at = dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
return True
|
||||
if resets_at.tzinfo is None:
|
||||
resets_at = resets_at.replace(tzinfo=dt.timezone.utc)
|
||||
return resets_at > now
|
||||
|
||||
|
||||
# How spent an account is: its fullest window that hasn't reset yet, in
|
||||
# percent. A window whose reset has passed counts as empty even when the
|
||||
# numbers are stale, since that's exactly what a reset means. None when
|
||||
# nothing is known at all.
|
||||
def peak(limits: Any, now: dt.datetime) -> float | None:
|
||||
if not isinstance(limits, list):
|
||||
return None
|
||||
known = [l for l in limits if isinstance(l, dict) and isinstance(l.get("percent"), (int, float))]
|
||||
if not known:
|
||||
return None
|
||||
open_windows = [float(l["percent"]) * 100 for l in known if window_open(l, now)]
|
||||
return max(open_windows) if open_windows else 0.0
|
||||
|
||||
|
||||
def next_reset(limits: Any, now: dt.datetime) -> dt.datetime | None:
|
||||
times = []
|
||||
for limit in limits if isinstance(limits, list) else []:
|
||||
try:
|
||||
when = dt.datetime.fromisoformat(str(limit.get("resetsAt") or "").replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
continue
|
||||
if when.tzinfo is None:
|
||||
when = when.replace(tzinfo=dt.timezone.utc)
|
||||
if when > now:
|
||||
times.append(when)
|
||||
return min(times) if times else None
|
||||
|
||||
|
||||
# When an account over the threshold drops back under it: once every window
|
||||
# holding it there has reset, which is the latest of those resets. A 10%
|
||||
# session resetting in an hour doesn't help an account whose weekly window is
|
||||
# full for six more days. None when a blocking window never says.
|
||||
def available_at(limits: Any, threshold: float, now: dt.datetime) -> dt.datetime | None:
|
||||
latest = None
|
||||
for limit in limits if isinstance(limits, list) else []:
|
||||
if not isinstance(limit, dict) or not isinstance(limit.get("percent"), (int, float)):
|
||||
continue
|
||||
if float(limit["percent"]) * 100 < threshold or not window_open(limit, now):
|
||||
continue
|
||||
try:
|
||||
when = dt.datetime.fromisoformat(str(limit.get("resetsAt") or "").replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
return None
|
||||
if when.tzinfo is None:
|
||||
when = when.replace(tzinfo=dt.timezone.utc)
|
||||
latest = when if latest is None or when > latest else latest
|
||||
return latest
|
||||
|
||||
|
||||
def duration(delta: dt.timedelta) -> str:
|
||||
minutes = max(1, int(delta.total_seconds() // 60))
|
||||
hours, minutes = divmod(minutes, 60)
|
||||
days, hours = divmod(hours, 24)
|
||||
if days:
|
||||
return f"{days}d {hours}h"
|
||||
if hours:
|
||||
return f"{hours}h {minutes}m"
|
||||
return f"{minutes}m"
|
||||
|
||||
|
||||
# The whole switching policy, with no I/O: given the registry and the per
|
||||
# account limits from the usage record, what should happen. Returns None when
|
||||
# nothing should, or {"action": "switch"|"notify"|"exhausted", ...}.
|
||||
#
|
||||
# Only the active account crossing the threshold starts anything, so auto
|
||||
# mode never flaps back to an account that just reset. `alert` remembers the
|
||||
# last thing said, so a shell restart or the next refresh doesn't repeat it.
|
||||
#
|
||||
# Numbers kept from an earlier check may be out of date, but a parked account's
|
||||
# sign-in lapses within hours and can't be checked again until a session runs
|
||||
# in it. So a stale account is still a candidate: among accounts with room, one
|
||||
# checked just now wins, but room comes first. An account within 15 points of
|
||||
# the threshold (where the panel starts checking more often) is near its limit
|
||||
# however fresh its numbers are.
|
||||
def decide(registry: dict[str, Any], usage: dict[str, Any], now: dt.datetime, stale: frozenset[str] = frozenset()) -> dict[str, Any] | None:
|
||||
threshold = float(registry["threshold"])
|
||||
current = active(registry)
|
||||
current_peak = peak(usage.get(current["id"]), now)
|
||||
if current_peak is None or current_peak < threshold:
|
||||
return {"action": "clear"} if registry.get("alert") else None
|
||||
|
||||
candidates = []
|
||||
for account in registry["accounts"]:
|
||||
if account["id"] == current["id"]:
|
||||
continue
|
||||
level = peak(usage.get(account["id"]), now)
|
||||
if level is None or level >= threshold:
|
||||
continue
|
||||
reset = next_reset(usage.get(account["id"]), now) or dt.datetime.max.replace(tzinfo=dt.timezone.utc)
|
||||
candidates.append((level >= threshold - 15, account["id"] in stale, level, reset, account))
|
||||
candidates.sort(key=lambda c: c[:4])
|
||||
|
||||
if candidates:
|
||||
_, _, level, _, target = candidates[0]
|
||||
action = "switch" if registry["switch"] == "auto" else "notify"
|
||||
alert = f"{action}:{current['id']}:{target['id']}"
|
||||
if registry.get("alert") == alert:
|
||||
return None
|
||||
return {"action": action, "alert": alert, "from": current, "to": target, "fromPeak": current_peak, "toPeak": level}
|
||||
|
||||
alert = f"exhausted:{current['id']}"
|
||||
if registry.get("alert") == alert or len(registry["accounts"]) < 2:
|
||||
return None
|
||||
# Only when every account is known to be over. One that's signed out or
|
||||
# couldn't be checked might have room, so there's nothing honest to say.
|
||||
if any(peak(usage.get(a["id"]), now) is None for a in registry["accounts"]):
|
||||
return None
|
||||
soonest = None
|
||||
for account in registry["accounts"]:
|
||||
reset = available_at(usage.get(account["id"]), threshold, now)
|
||||
if reset and (soonest is None or reset < soonest[0]):
|
||||
soonest = (reset, account)
|
||||
return {"action": "exhausted", "alert": alert, "from": current, "fromPeak": current_peak, "soonest": soonest}
|
||||
|
||||
|
||||
# Limits per account id. An account nobody is signed in to is left out, so it
|
||||
# is never a candidate: its cached headroom is no use to a session that can't
|
||||
# start. One whose token merely expired stays in, since a session refreshes it.
|
||||
def usage_by_account(provider: str) -> tuple[dict[str, Any], frozenset[str]]:
|
||||
record = read_json(usage_record_path(provider)) or {}
|
||||
accounts = [
|
||||
a for a in record.get("accounts") or []
|
||||
if isinstance(a, dict) and a.get("id") and a.get("usageStatusText") != "Waiting for auth"
|
||||
]
|
||||
return (
|
||||
{str(a["id"]): a.get("limits") for a in accounts},
|
||||
frozenset(str(a["id"]) for a in accounts if a.get("stale") is True),
|
||||
)
|
||||
|
||||
|
||||
def notify(title: str, body: str, action: list[str] | None = None) -> None:
|
||||
command = ["omarchy-notification-send", "--app-name", "Omarchy Agents", "-g", "", title, body]
|
||||
if action:
|
||||
command += ["--exec"] + action
|
||||
subprocess.run(command, check=False)
|
||||
|
||||
|
||||
# Returns True when the active account changed.
|
||||
def autoswitch(provider: str, now: dt.datetime | None = None) -> bool:
|
||||
now = now or dt.datetime.now(dt.timezone.utc)
|
||||
name = PROVIDERS[provider]["name"]
|
||||
with Locked(provider) as registry:
|
||||
if len(registry["accounts"]) < 2:
|
||||
return False
|
||||
usage, stale = usage_by_account(provider)
|
||||
decision = decide(registry, usage, now, stale)
|
||||
if decision is None:
|
||||
return False
|
||||
if decision["action"] == "clear":
|
||||
registry["alert"] = ""
|
||||
return False
|
||||
|
||||
registry["alert"] = decision["alert"]
|
||||
source = decision["from"]
|
||||
threshold = registry["threshold"]
|
||||
if decision["action"] == "exhausted":
|
||||
soonest = decision["soonest"]
|
||||
when = f" {soonest[1]['label']} resets in {duration(soonest[0] - now)}." if soonest else ""
|
||||
notify(f"All {name} accounts are over {threshold}%", f"Staying on {source['label']}.{when}")
|
||||
return False
|
||||
|
||||
target = decision["to"]
|
||||
headline = f"{source['label']} is at {round(decision['fromPeak'])}% of a {name} limit"
|
||||
if decision["action"] == "switch":
|
||||
registry["active"] = target["id"]
|
||||
if not target.get("primary"):
|
||||
link_shared(provider, account_home(provider, target))
|
||||
notify(headline, f"New {name} sessions now use {target['label']} ({round(decision['toPeak'])}%). Running sessions stay on {source['label']}.")
|
||||
return True
|
||||
notify(
|
||||
headline,
|
||||
f"{target['label']} is at {round(decision['toPeak'])}%. Click to switch new sessions to it.",
|
||||
["omarchy-agent-account-use", provider, target["id"]],
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- CLI
|
||||
|
||||
|
||||
def provider_arg(args: list[str]) -> str:
|
||||
if not args or args[0] not in PROVIDERS:
|
||||
raise AccountError("Name a provider: claude or codex")
|
||||
return args[0]
|
||||
|
||||
|
||||
def summary(provider: str, registry: dict[str, Any]) -> dict[str, Any]:
|
||||
# Identity is re-read from each home rather than trusted from the registry:
|
||||
# a `claude auth login` run by hand in a home changes who it is.
|
||||
for account in registry["accounts"]:
|
||||
account.update(identity(provider, account_home(provider, account)))
|
||||
return {
|
||||
"provider": provider,
|
||||
"name": PROVIDERS[provider]["name"],
|
||||
"active": registry["active"],
|
||||
"switch": registry["switch"],
|
||||
"threshold": registry["threshold"],
|
||||
"accounts": [
|
||||
{key: account.get(key, "") for key in ("id", "label", "email", "org", "plan", "primary")}
|
||||
| {
|
||||
"home": str(account_home(provider, account)),
|
||||
"active": account["id"] == registry["active"],
|
||||
"signedIn": bool(account.get("accountId")),
|
||||
}
|
||||
for account in registry["accounts"]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if not argv:
|
||||
print(__doc__.strip().splitlines()[0], file=sys.stderr)
|
||||
return 1
|
||||
command, args = argv[0], argv[1:]
|
||||
try:
|
||||
if command == "registry":
|
||||
provider = provider_arg(args)
|
||||
print(json.dumps(summary(provider, load(provider))))
|
||||
elif command == "list":
|
||||
providers = [provider_arg(args)] if args else list(PROVIDERS)
|
||||
print(json.dumps([summary(p, load(p)) for p in providers]))
|
||||
elif command == "home":
|
||||
# Empty for the primary, so launching it needs no environment at all.
|
||||
provider = provider_arg(args)
|
||||
registry = read_json(registry_path(provider))
|
||||
if registry:
|
||||
account = active(normalized(provider, registry))
|
||||
if not account.get("primary"):
|
||||
print(account_home(provider, account))
|
||||
elif command == "homes":
|
||||
provider = provider_arg(args)
|
||||
registry = load(provider)
|
||||
for account in registry["accounts"]:
|
||||
print("\t".join([account["id"], str(account_home(provider, account)), "1" if account["id"] == registry["active"] else "0"]))
|
||||
elif command == "refresh":
|
||||
for provider in ([provider_arg(args)] if args else list(PROVIDERS)):
|
||||
if registry_path(provider).exists():
|
||||
refresh(provider)
|
||||
elif command == "begin":
|
||||
print(begin(provider_arg(args)))
|
||||
elif command == "register":
|
||||
provider = provider_arg(args)
|
||||
if len(args) < 3:
|
||||
raise AccountError("register <provider> <label> <pending-home>")
|
||||
print(json.dumps(register(provider, args[1], Path(args[2]))))
|
||||
elif command == "use":
|
||||
provider = provider_arg(args)
|
||||
previous, account = use(provider, args[1] if len(args) > 1 else "next")
|
||||
print(json.dumps({"previous": previous, "active": account}))
|
||||
elif command == "remove":
|
||||
provider = provider_arg(args)
|
||||
if len(args) < 2:
|
||||
raise AccountError("remove <provider> <account>")
|
||||
print(json.dumps(remove(provider, args[1])))
|
||||
elif command == "rename":
|
||||
provider = provider_arg(args)
|
||||
if len(args) < 3:
|
||||
raise AccountError("rename <provider> <account> <label>")
|
||||
print(json.dumps(rename(provider, args[1], " ".join(args[2:]))))
|
||||
elif command == "mode":
|
||||
provider = provider_arg(args)
|
||||
if len(args) < 2:
|
||||
raise AccountError("mode <provider> <manual|auto> [threshold]")
|
||||
set_mode(provider, args[1], args[2] if len(args) > 2 else None)
|
||||
elif command == "link":
|
||||
provider = provider_arg(args)
|
||||
link_shared(provider, Path(args[1]))
|
||||
elif command == "autoswitch":
|
||||
# Prints each provider whose active account changed, so the caller
|
||||
# knows which usage records to collect again.
|
||||
for provider in ([provider_arg(args)] if args else list(PROVIDERS)):
|
||||
if registry_path(provider).exists() and autoswitch(provider):
|
||||
print(provider)
|
||||
else:
|
||||
raise AccountError(f"Unknown command: {command}")
|
||||
except AccountError as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Choose which Claude or Codex account new sessions use
|
||||
# omarchy:args=[claude|codex] [account|next]
|
||||
# omarchy:examples=omarchy agent account use work | omarchy agent account use main | omarchy agent account use codex next
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# The provider can be left out when it's your default agent:
|
||||
# `omarchy agent account use work`.
|
||||
if [[ ${1:-} != "claude" && ${1:-} != "codex" ]]; then
|
||||
default=$(omarchy-default-agent)
|
||||
[[ $default == "claude" || $default == "codex" ]] && set -- "$default" "$@"
|
||||
fi
|
||||
|
||||
case "${1:-}" in
|
||||
claude) name="Claude" ;;
|
||||
codex) name="Codex" ;;
|
||||
*)
|
||||
echo "Usage: omarchy-agent-account-use [claude|codex] [account|next]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
result=$(omarchy-agent-account-state use "$1" "${2:-next}") || exit 1
|
||||
|
||||
previous=$(jq -r .previous.label <<<"$result")
|
||||
active=$(jq -r '.active.label + (if (.active.plan // "") != "" then " (" + .active.plan + ")" else "" end)' <<<"$result")
|
||||
|
||||
if [[ $(jq -r .previous.id <<<"$result") == "$(jq -r .active.id <<<"$result")" ]]; then
|
||||
echo "New $name sessions already use $active."
|
||||
else
|
||||
message="Running sessions stay on $previous."
|
||||
echo "New $name sessions now use $active. $message"
|
||||
omarchy-notification-send -g "New $name sessions now use $active" "$message"
|
||||
# The usage record's own limits describe the active account.
|
||||
omarchy-agent-usage-update --limits-only "$1" >/dev/null 2>&1 &
|
||||
fi
|
||||
@@ -35,6 +35,10 @@ AGENT_NAME = "Claude Code"
|
||||
AUTH_HELP = "Run `claude auth login` to restore authoritative usage."
|
||||
USAGE_ENDPOINT = "https://api.anthropic.com/api/oauth/usage"
|
||||
PROBE_MIN_INTERVAL_SECONDS = 15
|
||||
# Anthropic rate-limits its usage endpoint readily. A refused re-check of
|
||||
# numbers this recent still describes the account; only older ones are
|
||||
# reported as stale.
|
||||
CURRENT_ENOUGH_SECONDS = 900
|
||||
|
||||
|
||||
def config_dir() -> Path:
|
||||
@@ -791,15 +795,23 @@ def usable_cached_limits(cached: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return [entry for entry in entries if isinstance(entry, dict) and limit_window_open(entry, now)]
|
||||
|
||||
|
||||
def collect_limits(access_token: str, expires_at_ms: int, force: bool) -> dict[str, Any]:
|
||||
result = {"limits": [], "usageStatusText": "", "authHelpText": AUTH_HELP}
|
||||
def collect_limits(access_token: str, expires_at_ms: int, force: bool, cache_name: str = "claude-limits.json") -> dict[str, Any]:
|
||||
# `live` says the numbers came from Anthropic just now (or within the reuse
|
||||
# window) rather than from a cache kept past a failed or impossible probe.
|
||||
result = {"limits": [], "usageStatusText": "", "authHelpText": AUTH_HELP, "live": False}
|
||||
|
||||
# When the numbers were measured, so a stale account can say how old
|
||||
# they are.
|
||||
result["fetchedAtMs"] = 0
|
||||
|
||||
# A panel that is opened and shut repeatedly must not turn into a request
|
||||
# per flick, so recent probe results are reused for a short window — and
|
||||
# kept as the answer of record when a later probe fails.
|
||||
probe_cache = cache_root() / "claude-limits.json"
|
||||
# kept as the answer of record when a later probe fails. Every account
|
||||
# keeps its own, so one account's allowance never stands in for another's.
|
||||
probe_cache = cache_root() / cache_name
|
||||
cached = read_fresh_json(probe_cache, float("inf")) or {}
|
||||
fallback = usable_cached_limits(cached)
|
||||
result["fetchedAtMs"] = number(cached.get("fetchedAtMs"))
|
||||
|
||||
# Probing needs a live token and only the Claude Code CLI can mint one: it
|
||||
# refreshes the credential file when it runs, so a machine left alone long
|
||||
@@ -826,12 +838,15 @@ def collect_limits(access_token: str, expires_at_ms: int, force: bool) -> dict[s
|
||||
fetched_at = number(cached.get("fetchedAtMs")) / 1000
|
||||
if fallback and not force and time.time() - fetched_at < PROBE_MIN_INTERVAL_SECONDS:
|
||||
result["limits"] = fallback
|
||||
result["live"] = True
|
||||
return result
|
||||
|
||||
probe = probe_limits(access_token)
|
||||
if probe["ok"]:
|
||||
result["limits"] = probe["limits"]
|
||||
write_json(probe_cache, {"fetchedAtMs": round(time.time() * 1000), "limits": probe["limits"]})
|
||||
result["live"] = True
|
||||
result["fetchedAtMs"] = round(time.time() * 1000)
|
||||
write_json(probe_cache, {"fetchedAtMs": result["fetchedAtMs"], "limits": probe["limits"]})
|
||||
return result
|
||||
|
||||
# The first probe after login often fires before DHCP has handed out a
|
||||
@@ -840,12 +855,102 @@ def collect_limits(access_token: str, expires_at_ms: int, force: bool) -> dict[s
|
||||
result["retryAdvised"] = True
|
||||
if fallback:
|
||||
result["limits"] = fallback
|
||||
result["live"] = time.time() - result["fetchedAtMs"] / 1000 < CURRENT_ENOUGH_SECONDS
|
||||
else:
|
||||
result["usageStatusText"] = "Claude limits unavailable"
|
||||
result["authHelpText"] = probe["helpText"]
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- accounts
|
||||
|
||||
|
||||
# The accounts `omarchy agent account` registered, read straight from its
|
||||
# registry. Only a registry holding a second account matters: with one, the
|
||||
# record is exactly what it always was.
|
||||
def registered_accounts() -> list[dict[str, Any]]:
|
||||
state = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state")
|
||||
try:
|
||||
registry = json.loads((state / "omarchy" / "agents" / "accounts" / "claude.json").read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
accounts = [a for a in registry.get("accounts") or [] if isinstance(a, dict) and a.get("id")]
|
||||
if len(accounts) < 2:
|
||||
return []
|
||||
active_id = registry.get("active") or accounts[0]["id"]
|
||||
if not any(a["id"] == active_id for a in accounts):
|
||||
active_id = accounts[0]["id"]
|
||||
for account in accounts:
|
||||
account["active"] = account["id"] == active_id
|
||||
account["switch"] = {
|
||||
"mode": "auto" if registry.get("switch") == "auto" else "manual",
|
||||
"threshold": registry.get("threshold") or 95,
|
||||
}
|
||||
return accounts
|
||||
|
||||
|
||||
# A parked account whose sign-in lapsed can't be probed, and once every window
|
||||
# it last saw has reset, the cache has nothing current to offer. That is not
|
||||
# "unknown" — a reset window is an untouched allowance, and switching needs to
|
||||
# know this account is the one with the most room. Each window it last saw
|
||||
# comes back at 0%.
|
||||
def replenished_limits(cache_name: str) -> list[dict[str, Any]]:
|
||||
cached = read_fresh_json(cache_root() / cache_name, float("inf")) or {}
|
||||
entries = cached.get("limits")
|
||||
if not isinstance(entries, list):
|
||||
return []
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
return [
|
||||
dict(entry, percent=0.0, resetsAt="")
|
||||
for entry in entries
|
||||
if isinstance(entry, dict) and not limit_window_open(entry, now)
|
||||
]
|
||||
|
||||
|
||||
def current_account_id(home: Path) -> str:
|
||||
path = Path.home() / ".claude.json" if home == expand_path("~/.claude") else home / ".claude.json"
|
||||
try:
|
||||
account = json.loads(path.read_text(encoding="utf-8")).get("oauthAccount") or {}
|
||||
except Exception:
|
||||
return ""
|
||||
return str(account.get("accountUuid") or "")
|
||||
|
||||
|
||||
def account_limits(account: dict[str, Any], force: bool) -> dict[str, Any]:
|
||||
home = expand_path(account["home"]) if account.get("home") else expand_path("~/.claude")
|
||||
access_token, expires_at_ms, plan = oauth_login(home)
|
||||
# Keyed by the subscription, not the label: forget "Work" and add a different
|
||||
# "Work", and the new one must never fall back on the old one's allowance.
|
||||
# Who the home is signed in as now, not when it was added: a `claude auth
|
||||
# login` run in it by hand makes it a different subscription.
|
||||
identity = current_account_id(home)
|
||||
key = re.sub(r"[^A-Za-z0-9_-]", "", identity or str(account.get("accountId") or "")) or account["id"]
|
||||
# The primary home is keyed the same way once it says who it's signed in
|
||||
# as, so signing it in to another subscription never inherits the last
|
||||
# one's numbers; until then it keeps the single-account cache.
|
||||
if account.get("primary") and not identity:
|
||||
cache_name = "claude-limits.json"
|
||||
else:
|
||||
cache_name = f"claude-limits-{key}.json"
|
||||
limits = collect_limits(access_token, expires_at_ms, force, cache_name)
|
||||
if not limits["live"] and not limits["limits"]:
|
||||
limits["limits"] = replenished_limits(cache_name)
|
||||
return {
|
||||
"id": account["id"],
|
||||
"label": str(account.get("label") or account["id"]),
|
||||
"email": str(account.get("email") or ""),
|
||||
"plan": plan or str(account.get("plan") or ""),
|
||||
"active": account["active"],
|
||||
"primary": bool(account.get("primary")),
|
||||
"limits": limits["limits"],
|
||||
"stale": not limits["live"],
|
||||
"fetchedAt": limits["fetchedAtMs"],
|
||||
"usageStatusText": limits["usageStatusText"],
|
||||
"authHelpText": limits["authHelpText"],
|
||||
"retryAdvised": bool(limits.get("retryAdvised")),
|
||||
}
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- record
|
||||
|
||||
|
||||
@@ -879,8 +984,19 @@ def main() -> int:
|
||||
if opencode is not None:
|
||||
stats = merge_stats(stats, opencode)
|
||||
|
||||
access_token, expires_at_ms, plan = oauth_login(claude_dir)
|
||||
limits = collect_limits(access_token, expires_at_ms, args.force)
|
||||
registered = registered_accounts()
|
||||
accounts = [account_limits(account, args.force) for account in registered]
|
||||
current = next((a for a in accounts if a["active"]), None)
|
||||
if current:
|
||||
# The top-level fields keep describing the account new sessions use, so
|
||||
# the bar icon and anything else reading one set of limits stays right.
|
||||
plan = current["plan"]
|
||||
limits = {key: current[key] for key in ("limits", "usageStatusText", "authHelpText", "retryAdvised")}
|
||||
stale, fetched_at = current["stale"], current["fetchedAt"]
|
||||
else:
|
||||
access_token, expires_at_ms, plan = oauth_login(claude_dir)
|
||||
limits = collect_limits(access_token, expires_at_ms, args.force)
|
||||
stale, fetched_at = not limits["live"], limits["fetchedAtMs"]
|
||||
|
||||
record = {
|
||||
"schemaVersion": 1,
|
||||
@@ -893,9 +1009,16 @@ def main() -> int:
|
||||
"usageStatusText": limits["usageStatusText"],
|
||||
"authHelpText": limits["authHelpText"],
|
||||
"limits": limits["limits"],
|
||||
# Numbers kept past a failed probe, and when they were measured, so the
|
||||
# panel can tell them apart from ones checked just now.
|
||||
"limitsStale": stale,
|
||||
"limitsFetchedAt": fetched_at,
|
||||
}
|
||||
if limits.get("retryAdvised"):
|
||||
record["retryAdvised"] = True
|
||||
if accounts:
|
||||
record["accountSwitch"] = registered[0]["switch"]
|
||||
record["accounts"] = accounts
|
||||
record.update(stats)
|
||||
print(json.dumps(record, separators=(",", ":"), sort_keys=True))
|
||||
return 0
|
||||
|
||||
@@ -518,8 +518,21 @@ def limit_window(window):
|
||||
}
|
||||
|
||||
|
||||
def fetch_codex_rpc():
|
||||
def reset_credits(result):
|
||||
granted = (result.get("rateLimitResetCredits") or {}).get("credits") or []
|
||||
available = [c for c in granted if isinstance(c, dict) and c.get("status") == "available"]
|
||||
if not available:
|
||||
return None
|
||||
expiries = [number(c.get("expiresAt")) for c in available if number(c.get("expiresAt")) > 0]
|
||||
return {
|
||||
"available": len(available),
|
||||
"nextExpiresAt": datetime.fromtimestamp(min(expiries), timezone.utc).isoformat() if expiries else "",
|
||||
}
|
||||
|
||||
|
||||
def fetch_codex_rpc(home=None):
|
||||
result = {"limits": [], "tierLabel": "", "usageStatusText": "", "authHelpText": AUTH_HELP}
|
||||
env = dict(ENV, CODEX_HOME=str(home)) if home else ENV
|
||||
codex = find_command("codex")
|
||||
if not codex:
|
||||
result["usageStatusText"] = "Codex unavailable"
|
||||
@@ -533,7 +546,7 @@ def fetch_codex_rpc():
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
env=ENV,
|
||||
env=env,
|
||||
)
|
||||
except Exception as exc:
|
||||
result["usageStatusText"] = "Codex unavailable"
|
||||
@@ -545,6 +558,20 @@ def fetch_codex_rpc():
|
||||
proc.stdin.write(json.dumps({"method": "initialized", "params": {}}) + "\n")
|
||||
proc.stdin.flush()
|
||||
limits_msg = rpc_request(proc, 2, "account/rateLimits/read", timeout=8)
|
||||
|
||||
# A home nobody is signed in to answers with an error rather than limits.
|
||||
# Say so the same way the Claude collector does, so the panel offers to
|
||||
# sign that account in again.
|
||||
error = limits_msg.get("error")
|
||||
if isinstance(error, dict):
|
||||
message = str(error.get("message") or "")
|
||||
if "auth" in message.lower():
|
||||
result["usageStatusText"] = "Waiting for auth"
|
||||
result["authHelpText"] = AUTH_HELP
|
||||
else:
|
||||
result["usageStatusText"] = "Codex limits unavailable"
|
||||
result["authHelpText"] = message
|
||||
return result
|
||||
limits = (limits_msg.get("result") or {}).get("rateLimits") or {}
|
||||
plan = limits.get("planType") or ""
|
||||
|
||||
@@ -564,6 +591,12 @@ def fetch_codex_rpc():
|
||||
entry = limit_window(window)
|
||||
if entry:
|
||||
result["limits"].append(entry)
|
||||
|
||||
# OpenAI hands out free "full reset" credits that wipe the rate limits on
|
||||
# demand and lapse after a month. Worth knowing about before a limit bites.
|
||||
credits = reset_credits(limits_msg.get("result") or {})
|
||||
if credits:
|
||||
result["resetCredits"] = credits
|
||||
except Exception as exc:
|
||||
result["usageStatusText"] = "Codex limits unavailable"
|
||||
result["authHelpText"] = str(exc)
|
||||
@@ -579,6 +612,51 @@ def fetch_codex_rpc():
|
||||
return result
|
||||
|
||||
|
||||
# The accounts `omarchy agent account` registered, read straight from its
|
||||
# registry. Only a registry holding a second account matters: with one, the
|
||||
# record is exactly what it always was.
|
||||
def registered_accounts():
|
||||
state = Path(os.environ.get("XDG_STATE_HOME") or (Path.home() / ".local" / "state"))
|
||||
try:
|
||||
registry = json.loads((state / "omarchy" / "agents" / "accounts" / "codex.json").read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
accounts = [a for a in registry.get("accounts") or [] if isinstance(a, dict) and a.get("id")]
|
||||
if len(accounts) < 2:
|
||||
return []
|
||||
active_id = registry.get("active") or accounts[0]["id"]
|
||||
if not any(a["id"] == active_id for a in accounts):
|
||||
active_id = accounts[0]["id"]
|
||||
for account in accounts:
|
||||
account["active"] = account["id"] == active_id
|
||||
account["switch"] = {
|
||||
"mode": "auto" if registry.get("switch") == "auto" else "manual",
|
||||
"threshold": registry.get("threshold") or 95,
|
||||
}
|
||||
return accounts
|
||||
|
||||
|
||||
# Each account's app-server runs in that account's home, so it answers for that
|
||||
# login and refreshes that login's tokens there.
|
||||
def account_limits(account):
|
||||
# The primary is ~/.codex by definition, not whatever CODEX_HOME this run
|
||||
# happened to inherit — that would report another account's limits as Main's.
|
||||
rpc = fetch_codex_rpc(account.get("home") or (Path.home() / ".codex"))
|
||||
return {
|
||||
"id": account["id"],
|
||||
"label": str(account.get("label") or account["id"]),
|
||||
"email": str(account.get("email") or ""),
|
||||
"plan": rpc["tierLabel"] or str(account.get("plan") or ""),
|
||||
"active": account["active"],
|
||||
"primary": bool(account.get("primary")),
|
||||
"limits": rpc["limits"],
|
||||
"stale": not rpc["limits"],
|
||||
"usageStatusText": rpc["usageStatusText"],
|
||||
"authHelpText": rpc["authHelpText"],
|
||||
"resetCredits": rpc.get("resetCredits"),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
# --force rescans everything and rewrites the cache. --limits-only is kept
|
||||
@@ -592,7 +670,21 @@ def main():
|
||||
|
||||
max_age = 0 if args.force else (LIMITS_ONLY_REUSE_SECONDS if args.limits_only else SCAN_REUSE_SECONDS)
|
||||
stats = cached_local_stats(max_age)
|
||||
rpc = fetch_codex_rpc()
|
||||
registered = registered_accounts()
|
||||
accounts = [account_limits(account) for account in registered]
|
||||
current = next((a for a in accounts if a["active"]), None)
|
||||
if current:
|
||||
# The top-level fields keep describing the account new sessions use.
|
||||
rpc = {
|
||||
"limits": current["limits"],
|
||||
"tierLabel": current["plan"],
|
||||
"usageStatusText": current["usageStatusText"],
|
||||
"authHelpText": current["authHelpText"],
|
||||
}
|
||||
if current.get("resetCredits"):
|
||||
rpc["resetCredits"] = current["resetCredits"]
|
||||
else:
|
||||
rpc = fetch_codex_rpc()
|
||||
|
||||
record = {
|
||||
"schemaVersion": 1,
|
||||
@@ -604,6 +696,9 @@ def main():
|
||||
}
|
||||
record.update(stats)
|
||||
record.update(rpc)
|
||||
if accounts:
|
||||
record["accountSwitch"] = registered[0]["switch"]
|
||||
record["accounts"] = accounts
|
||||
print(json.dumps(record, separators=(",", ":")))
|
||||
|
||||
|
||||
|
||||
@@ -51,6 +51,13 @@ collect() {
|
||||
mv "$tmp" "$USAGE_DIR/$agent.json"
|
||||
}
|
||||
|
||||
# Accounts can be signed in to someone else since they were added; the
|
||||
# records name them from the registry, so bring it up to date first.
|
||||
accounts_dir="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/agents/accounts"
|
||||
if [[ -f $accounts_dir/claude.json || -f $accounts_dir/codex.json ]]; then
|
||||
"$OMARCHY_PATH/bin/omarchy-agent-account-state" refresh
|
||||
fi
|
||||
|
||||
pids=()
|
||||
for collector in "$OMARCHY_PATH"/bin/omarchy-agent-usage-*; do
|
||||
[[ -x $collector ]] || continue
|
||||
@@ -65,4 +72,18 @@ status=0
|
||||
for pid in "${pids[@]}"; do
|
||||
wait "$pid" || status=1
|
||||
done
|
||||
|
||||
# Fresh limits are when switching accounts near a limit gets decided. A switch
|
||||
# changes which account the record's own limits describe, so that record is
|
||||
# collected once more; the limits probes it repeats are still cached.
|
||||
for agent in claude codex; do
|
||||
if wanted "$agent" && [[ -f $accounts_dir/$agent.json ]]; then
|
||||
switched=$("$OMARCHY_PATH/bin/omarchy-agent-account-state" autoswitch "$agent")
|
||||
if [[ -n $switched ]]; then
|
||||
flags=(--limits-only)
|
||||
collect "$OMARCHY_PATH/bin/omarchy-agent-usage-$agent" "$agent" || status=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
exit $status
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
name: omarchy-app
|
||||
description: >
|
||||
Build a new desktop app for Omarchy the way Omarchy's own apps are built
|
||||
(Hype, Monologue, Omacut): C++ and Qt Quick, compiled with qmake6 and make
|
||||
into a single binary, following the Omarchy theme live, and installed as an
|
||||
Arch package so it shows up in the app launcher. Use when asked to make,
|
||||
build, or scaffold an app, tool, utility, or GUI program for the desktop.
|
||||
Triggers: new app, make me an app, desktop app, GUI, Qt, QML, Qt Quick, C++
|
||||
app, qmake, app launcher entry, PKGBUILD for my app.
|
||||
---
|
||||
|
||||
# Building an Omarchy App
|
||||
|
||||
Omarchy's own apps are dead simple on purpose: one window, one job, driven
|
||||
from the keyboard, following the desktop theme, and installed like any other
|
||||
package. Build new ones the same way. `templates.md` has the starter files;
|
||||
the published apps are the reference for anything bigger:
|
||||
|
||||
- Omacut, a video trimmer: https://github.com/omacom-io/omacut
|
||||
- Monologue, a webcam recorder: https://github.com/omacom/monologue
|
||||
- Hype, Markdown presentations: https://github.com/omacom/hype
|
||||
|
||||
## Before writing code
|
||||
|
||||
Ask what the app should do if it isn't clear, and settle the one job it does.
|
||||
Pick a short lowercase name (`tally`, `pomo`); it is the binary, the package,
|
||||
the `.desktop` file, the icon, and the Wayland app id all at once. Create the
|
||||
project in `~/Work/<name>` unless the user says otherwise, and `git init` it.
|
||||
|
||||
Everything an app needs is part of Omarchy: `qmake6`, `make`, a C++
|
||||
compiler, Qt's base, declarative, multimedia, SVG, and Wayland modules, and
|
||||
`ffmpeg`. If `qmake6` is missing, install the set with `omarchy-pkg-add
|
||||
base-devel qt6-base qt6-declarative qt6-multimedia qt6-svg qt6-wayland ffmpeg`.
|
||||
|
||||
## Shape
|
||||
|
||||
```text
|
||||
<name>.pro qmake project: Qt modules, sources, resources
|
||||
bin/build qmake6 + make into build/<name>
|
||||
bin/test builds and runs the Qt Test binary offscreen
|
||||
bin/install makepkg -fsi, so the app lands in the launcher
|
||||
src/main.cpp app setup; hands C++ objects to QML
|
||||
src/backend.{h,cpp} the app's state and work, as a QObject
|
||||
src/theme.{h,cpp} follows the Omarchy accent color
|
||||
src/Main.qml the window: layout, keys, nothing else
|
||||
src/resources.qrc compiles the QML into the binary
|
||||
tests/<name>_tests.{pro,cpp}
|
||||
pkgbuild/PKGBUILD pkgbuild/<name>.desktop pkgbuild/<name>.svg
|
||||
README.md LICENSE .gitignore
|
||||
```
|
||||
|
||||
`src/` stays flat. No CMake, no build directories checked in, no generated
|
||||
files committed.
|
||||
|
||||
## How the pieces split
|
||||
|
||||
- **C++ owns the logic**: state, files, processes, anything that can fail.
|
||||
Expose it as a `QObject` with `Q_PROPERTY … NOTIFY` for state and
|
||||
`Q_INVOKABLE` for actions, handed to QML with
|
||||
`engine.rootContext()->setContextProperty("backend", &backend)`.
|
||||
- **QML owns layout and interaction**: an `ApplicationWindow` in `Main.qml`,
|
||||
a few components beside it, and every key binding as a `Shortcut` with
|
||||
`context: Qt.ApplicationShortcut`.
|
||||
- Use `QGuiApplication`, not `QApplication`. Call
|
||||
`app.setDesktopFileName("<name>")` so the compositor and launcher match the
|
||||
window to its `.desktop` file and icon.
|
||||
- Use the Material style with `Material.theme: Material.Dark` and the theme
|
||||
accent as `Material.accent`, on a dark window color.
|
||||
- Heavy media work goes through the `ffmpeg`/`ffprobe` command-line tools via
|
||||
`QProcess`, not by linking libav.
|
||||
- Open and save dialogs go through xdg-desktop-portal over D-Bus
|
||||
(`org.freedesktop.portal.FileChooser`), never `QFileDialog`. Omacut's
|
||||
`src/portalfilepicker.{h,cpp}` behind an abstract `src/filepicker.h` is the
|
||||
pattern to copy, with its attribution, and lets tests inject a fake picker.
|
||||
|
||||
## Omarchy conventions
|
||||
|
||||
- **Follow the theme live.** Read `accent` from
|
||||
`~/.local/state/omarchy/current/theme/colors.toml` and watch it; a theme
|
||||
switch replaces files and symlinks, so re-arm the watcher on every change.
|
||||
Fall back to `#FFD60A` when Omarchy isn't there. `templates.md` has the
|
||||
`Theme` class. Never change Omarchy's config.
|
||||
- **Keyboard first.** `?` shows every shortcut in an overlay, `Q` quits (and
|
||||
asks first when there's unsaved or unexported work), Space is the main
|
||||
action, Ctrl+Z / Ctrl+Shift+Z undo and redo. The README lists the shortcuts
|
||||
in a table.
|
||||
- **Careful with data.** Never modify the user's originals. Save atomically
|
||||
(write beside the target, then rename). Ask before discarding work.
|
||||
- **Say what's wrong.** A missing tool, device, or permission is reported in
|
||||
the window, never worked around by quietly doing less.
|
||||
- Comments explain why, in full sentences. Commit messages are short and
|
||||
imperative ("Trim from the keyboard").
|
||||
|
||||
## Loop
|
||||
|
||||
1. `bin/build`, then run `build/<name>` to try it. Pass a file argument if
|
||||
the app opens files, so it's quick to test.
|
||||
2. `bin/test` after each change to the backend. Tests compile `../src/*.cpp`
|
||||
straight in, run offscreen, and pass temp directories to anything that
|
||||
reads the disk (the `Theme` takes the theme directory as an argument).
|
||||
3. Look at it. Take a screenshot with `omarchy capture screenshot fullscreen
|
||||
save` and check the window against the theme before calling it done.
|
||||
4. `bin/install` builds the package with `makepkg -fsi`, which installs the
|
||||
binary, the `.desktop` entry, and the icon. The app then appears in the
|
||||
Omarchy app launcher (`Super + Space`). Run it again after every change
|
||||
the user should see there.
|
||||
@@ -0,0 +1,431 @@
|
||||
# Omarchy App Templates
|
||||
|
||||
Read this when starting an app from the omarchy-app skill. Replace `<name>`
|
||||
with the app's name and `<Name>` with its display name. These come from
|
||||
Omacut and Monologue (MIT, David Heinemeier Hansson); keep that attribution in
|
||||
the LICENSE of any app that copies them.
|
||||
|
||||
## `<name>.pro`
|
||||
|
||||
Add `multimedia`, `concurrent`, or other modules only when the app uses them.
|
||||
|
||||
```qmake
|
||||
QT += core gui qml quick quickcontrols2 dbus
|
||||
|
||||
CONFIG += c++17 release
|
||||
TARGET = <name>
|
||||
TEMPLATE = app
|
||||
|
||||
HEADERS += \
|
||||
src/backend.h \
|
||||
src/theme.h
|
||||
|
||||
SOURCES += \
|
||||
src/main.cpp \
|
||||
src/backend.cpp \
|
||||
src/theme.cpp
|
||||
|
||||
RESOURCES += src/resources.qrc
|
||||
```
|
||||
|
||||
## `bin/build`
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
mkdir -p "$ROOT/build"
|
||||
cd "$ROOT/build"
|
||||
|
||||
qmake6 "$ROOT/<name>.pro"
|
||||
make -j"$(nproc)"
|
||||
|
||||
echo "Built $ROOT/build/<name>"
|
||||
```
|
||||
|
||||
## `bin/test`
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
mkdir -p "$ROOT/build/tests"
|
||||
cd "$ROOT/build/tests"
|
||||
|
||||
qmake6 "$ROOT/tests/<name>_tests.pro"
|
||||
make -j"$(nproc)"
|
||||
QT_QPA_PLATFORM=offscreen ./<name>_tests
|
||||
```
|
||||
|
||||
## `bin/install`
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
"$ROOT/bin/build"
|
||||
cd "$ROOT/pkgbuild"
|
||||
exec makepkg -fsi "$@"
|
||||
```
|
||||
|
||||
## `src/main.cpp`
|
||||
|
||||
```cpp
|
||||
// <name> — one line on what it does.
|
||||
|
||||
#include <QGuiApplication>
|
||||
#include <QIcon>
|
||||
#include <QQmlApplicationEngine>
|
||||
#include <QQmlContext>
|
||||
#include <QQuickStyle>
|
||||
#include <QUrl>
|
||||
|
||||
#include "backend.h"
|
||||
#include "theme.h"
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
QGuiApplication app(argc, argv);
|
||||
app.setApplicationName("<name>");
|
||||
app.setApplicationVersion("0.1.0");
|
||||
|
||||
// Matches the window to <name>.desktop, so the compositor (Wayland app_id)
|
||||
// and the launcher pick up the installed icon.
|
||||
app.setDesktopFileName("<name>");
|
||||
app.setWindowIcon(QIcon::fromTheme("<name>"));
|
||||
|
||||
QQuickStyle::setStyle("Material");
|
||||
|
||||
Theme theme;
|
||||
Backend backend;
|
||||
|
||||
QQmlApplicationEngine engine;
|
||||
engine.rootContext()->setContextProperty("theme", &theme);
|
||||
engine.rootContext()->setContextProperty("backend", &backend);
|
||||
engine.load(QUrl("qrc:/Main.qml"));
|
||||
if (engine.rootObjects().isEmpty())
|
||||
return 1;
|
||||
|
||||
return app.exec();
|
||||
}
|
||||
```
|
||||
|
||||
## `src/theme.h` and `src/theme.cpp`
|
||||
|
||||
Follows the Omarchy accent live, and hands QML a readable color to put on it.
|
||||
Tests construct it with a temp directory.
|
||||
|
||||
```cpp
|
||||
#pragma once
|
||||
|
||||
#include <QFileSystemWatcher>
|
||||
#include <QObject>
|
||||
#include <QTimer>
|
||||
|
||||
class Theme : public QObject {
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(QString accent READ accent NOTIFY changed)
|
||||
Q_PROPERTY(QString accentForeground READ accentForeground NOTIFY changed)
|
||||
|
||||
public:
|
||||
explicit Theme(const QString ¤tDirectory = {}, QObject *parent = nullptr);
|
||||
|
||||
QString accent() const { return m_accent; }
|
||||
QString accentForeground() const;
|
||||
|
||||
static QString readAccent(const QString &path);
|
||||
|
||||
signals:
|
||||
void changed();
|
||||
|
||||
private:
|
||||
void reload();
|
||||
|
||||
QString m_directory;
|
||||
QString m_accent = "#FFD60A";
|
||||
QFileSystemWatcher m_watcher;
|
||||
QTimer m_debounce;
|
||||
};
|
||||
```
|
||||
|
||||
```cpp
|
||||
// Theme watching follows Omacut (MIT, David Heinemeier Hansson).
|
||||
#include "theme.h"
|
||||
|
||||
#include <QColor>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QRegularExpression>
|
||||
#include <cmath>
|
||||
|
||||
Theme::Theme(const QString &directory, QObject *parent)
|
||||
: QObject(parent),
|
||||
m_directory(directory.isEmpty() ? QDir::homePath() + "/.local/state/omarchy/current" : directory) {
|
||||
m_debounce.setSingleShot(true);
|
||||
m_debounce.setInterval(80);
|
||||
connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, [this] { m_debounce.start(); });
|
||||
connect(&m_watcher, &QFileSystemWatcher::fileChanged, this, [this] { m_debounce.start(); });
|
||||
connect(&m_debounce, &QTimer::timeout, this, &Theme::reload);
|
||||
reload();
|
||||
}
|
||||
|
||||
QString Theme::readAccent(const QString &path) {
|
||||
QFile file(path);
|
||||
if (file.open(QIODevice::ReadOnly)) {
|
||||
const QRegularExpression expression(R"re(^\s*accent\s*=\s*["'](#[0-9a-fA-F]{6})["'])re",
|
||||
QRegularExpression::MultilineOption);
|
||||
const auto match = expression.match(QString::fromUtf8(file.readAll()));
|
||||
if (match.hasMatch())
|
||||
return match.captured(1);
|
||||
}
|
||||
return "#FFD60A";
|
||||
}
|
||||
|
||||
// Black or white, whichever reads on the accent (WCAG relative luminance).
|
||||
QString Theme::accentForeground() const {
|
||||
const QColor c(m_accent);
|
||||
auto linear = [](double v) { return v <= .04045 ? v / 12.92 : std::pow((v + .055) / 1.055, 2.4); };
|
||||
const double luminance = .2126 * linear(c.redF()) + .7152 * linear(c.greenF()) + .0722 * linear(c.blueF());
|
||||
return luminance > .179 ? "black" : "white";
|
||||
}
|
||||
|
||||
void Theme::reload() {
|
||||
const auto paths = m_watcher.files() + m_watcher.directories();
|
||||
if (!paths.isEmpty())
|
||||
m_watcher.removePaths(paths);
|
||||
|
||||
// A theme switch replaces symlinks and files, so watch the parents too and
|
||||
// re-arm after every change. Watching the nearest existing ancestor means
|
||||
// installing Omarchy later works without restarting the app.
|
||||
QString ancestor = m_directory;
|
||||
while (!QFileInfo::exists(ancestor) && ancestor != "/")
|
||||
ancestor = QFileInfo(ancestor).absolutePath();
|
||||
QStringList candidates{ancestor, QFileInfo(ancestor).absolutePath(), m_directory,
|
||||
m_directory + "/theme", m_directory + "/theme/colors.toml"};
|
||||
candidates.removeDuplicates();
|
||||
for (const auto &path : candidates)
|
||||
if (QFileInfo::exists(path))
|
||||
m_watcher.addPath(path);
|
||||
|
||||
const auto accent = readAccent(m_directory + "/theme/colors.toml");
|
||||
if (accent != m_accent) {
|
||||
m_accent = accent;
|
||||
emit changed();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## `src/backend.h` and `src/backend.cpp`
|
||||
|
||||
The C++ side of the app's one job, exposed to QML as `backend`. Start with
|
||||
the single action the Space shortcut runs, then grow it.
|
||||
|
||||
```cpp
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
|
||||
class Backend : public QObject {
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(int count READ count NOTIFY countChanged)
|
||||
|
||||
public:
|
||||
explicit Backend(QObject *parent = nullptr) : QObject(parent) {}
|
||||
|
||||
int count() const { return m_count; }
|
||||
Q_INVOKABLE void primaryAction();
|
||||
|
||||
signals:
|
||||
void countChanged();
|
||||
|
||||
private:
|
||||
int m_count = 0;
|
||||
};
|
||||
```
|
||||
|
||||
```cpp
|
||||
#include "backend.h"
|
||||
|
||||
void Backend::primaryAction()
|
||||
{
|
||||
++m_count;
|
||||
emit countChanged();
|
||||
}
|
||||
```
|
||||
|
||||
## `src/Main.qml`
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.Material
|
||||
import QtQuick.Layouts
|
||||
|
||||
ApplicationWindow {
|
||||
id: win
|
||||
width: 960
|
||||
height: 680
|
||||
minimumWidth: 640
|
||||
minimumHeight: 460
|
||||
visible: true
|
||||
title: "<name>"
|
||||
color: "#0e0e10"
|
||||
|
||||
Material.theme: Material.Dark
|
||||
Material.accent: theme.accent
|
||||
|
||||
property bool helpVisible: false
|
||||
|
||||
Shortcut { sequence: "?"; context: Qt.ApplicationShortcut; onActivated: win.helpVisible = !win.helpVisible }
|
||||
Shortcut { sequence: "Q"; context: Qt.ApplicationShortcut; onActivated: Qt.quit() }
|
||||
Shortcut { sequence: "Space"; context: Qt.ApplicationShortcut; onActivated: backend.primaryAction() }
|
||||
|
||||
// The app's one job goes here.
|
||||
}
|
||||
```
|
||||
|
||||
## `src/resources.qrc`
|
||||
|
||||
```xml
|
||||
<RCC>
|
||||
<qresource prefix="/">
|
||||
<file alias="Main.qml">Main.qml</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
```
|
||||
|
||||
## `tests/<name>_tests.pro`
|
||||
|
||||
```qmake
|
||||
QT += core gui quick quickcontrols2 testlib dbus
|
||||
CONFIG += c++17 testcase
|
||||
TARGET = <name>_tests
|
||||
TEMPLATE = app
|
||||
|
||||
INCLUDEPATH += ../src
|
||||
|
||||
HEADERS += ../src/backend.h ../src/theme.h
|
||||
SOURCES += <name>_tests.cpp ../src/backend.cpp ../src/theme.cpp
|
||||
```
|
||||
|
||||
## `tests/<name>_tests.cpp`
|
||||
|
||||
One `private slots:` case per behavior. These cover the backend and the
|
||||
theme fallback and accent read, so a broken theme shows up in `bin/test`.
|
||||
|
||||
```cpp
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QTemporaryDir>
|
||||
#include <QtTest>
|
||||
|
||||
#include "backend.h"
|
||||
#include "theme.h"
|
||||
|
||||
class <Name>Tests : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void primaryActionCounts()
|
||||
{
|
||||
Backend backend;
|
||||
backend.primaryAction();
|
||||
QCOMPARE(backend.count(), 1);
|
||||
}
|
||||
|
||||
void themeFallsBack()
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
Theme theme(dir.path());
|
||||
QCOMPARE(theme.accent(), QString("#FFD60A"));
|
||||
QCOMPARE(theme.accentForeground(), QString("black"));
|
||||
}
|
||||
|
||||
void themeReadsAccent()
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
QDir(dir.path()).mkpath("theme");
|
||||
QFile colors(dir.path() + "/theme/colors.toml");
|
||||
QVERIFY(colors.open(QIODevice::WriteOnly));
|
||||
colors.write("accent = \"#112233\"\n");
|
||||
colors.close();
|
||||
|
||||
Theme theme(dir.path());
|
||||
QCOMPARE(theme.accent(), QString("#112233"));
|
||||
QCOMPARE(theme.accentForeground(), QString("white"));
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_MAIN(<Name>Tests)
|
||||
#include "<name>_tests.moc"
|
||||
```
|
||||
|
||||
## `pkgbuild/PKGBUILD`
|
||||
|
||||
```sh
|
||||
pkgname=<name>
|
||||
pkgver=0.1.0
|
||||
pkgrel=1
|
||||
pkgdesc='One line on what it does'
|
||||
arch=('x86_64' 'aarch64')
|
||||
license=('MIT')
|
||||
depends=('qt6-base' 'qt6-declarative' 'qt6-wayland' 'xdg-desktop-portal')
|
||||
makedepends=('gcc' 'make')
|
||||
source=()
|
||||
sha256sums=()
|
||||
|
||||
build() {
|
||||
cd "$startdir/.."
|
||||
./bin/build
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$startdir/.."
|
||||
install -Dm755 build/<name> "$pkgdir/usr/bin/<name>"
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
install -Dm644 "$startdir/<name>.svg" "$pkgdir/usr/share/icons/hicolor/scalable/apps/<name>.svg"
|
||||
install -Dm644 "$startdir/<name>.desktop" "$pkgdir/usr/share/applications/<name>.desktop"
|
||||
}
|
||||
```
|
||||
|
||||
Add `ffmpeg`, `qt6-multimedia`, or `qt6-svg` to `depends` when the app uses
|
||||
them. Omarchy installs all three, but the package should still say what it
|
||||
needs. `package()` also installs `LICENSE` from the project root (the MIT text
|
||||
with the user's name and year) and the icon below, so both must exist.
|
||||
|
||||
## `pkgbuild/<name>.desktop`
|
||||
|
||||
```ini
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=<Name>
|
||||
Comment=One line on what it does
|
||||
Exec=<name> %f
|
||||
Icon=<name>
|
||||
Terminal=false
|
||||
Categories=Utility;
|
||||
StartupWMClass=<name>
|
||||
```
|
||||
|
||||
## `pkgbuild/<name>.svg`
|
||||
|
||||
The icon is a single square SVG. Start from a rounded square in the accent
|
||||
and draw the app's mark on it.
|
||||
|
||||
```svg
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<rect width="64" height="64" rx="14" fill="#FFD60A"/>
|
||||
</svg>
|
||||
```
|
||||
|
||||
## `.gitignore`
|
||||
|
||||
```text
|
||||
/build/
|
||||
/pkgbuild/pkg/
|
||||
/pkgbuild/src/
|
||||
*.pkg.tar.*
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Start Claude Code and Codex as the active subscription account, so `cx`,
|
||||
# `cy`, `tdl` and a plain `claude` all follow `omarchy agent account use`.
|
||||
# An explicitly set CLAUDE_CONFIG_DIR / CODEX_HOME still wins.
|
||||
claude() {
|
||||
local home=""
|
||||
[[ -z ${CLAUDE_CONFIG_DIR:-} ]] && home=$(omarchy-agent-account-home claude)
|
||||
|
||||
if [[ -n $home ]]; then
|
||||
CLAUDE_CONFIG_DIR="$home" command claude "$@"
|
||||
else
|
||||
command claude "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
codex() {
|
||||
local home=""
|
||||
[[ -z ${CODEX_HOME:-} ]] && home=$(omarchy-agent-account-home codex)
|
||||
|
||||
if [[ -n $home ]]; then
|
||||
CODEX_HOME="$home" command codex "$@"
|
||||
else
|
||||
command codex "$@"
|
||||
fi
|
||||
}
|
||||
@@ -5,6 +5,7 @@ aether
|
||||
alsa-utils
|
||||
asdcontrol
|
||||
avahi
|
||||
base-devel
|
||||
bash-completion
|
||||
bat
|
||||
bluez
|
||||
@@ -36,6 +37,7 @@ fcitx5
|
||||
fcitx5-gtk
|
||||
fcitx5-qt
|
||||
fd
|
||||
ffmpeg
|
||||
ffmpegthumbnailer
|
||||
fontconfig
|
||||
foot
|
||||
@@ -110,6 +112,11 @@ postgresql-libs
|
||||
power-profiles-daemon
|
||||
python-gobject
|
||||
python-poetry-core
|
||||
qt6-base
|
||||
qt6-declarative
|
||||
qt6-multimedia
|
||||
qt6-svg
|
||||
qt6-wayland
|
||||
ttfx
|
||||
qemu-user-static-binfmt
|
||||
qrencode
|
||||
|
||||
@@ -25,7 +25,7 @@ Common commands:
|
||||
omarchy debug Print debugging information
|
||||
|
||||
Groups:
|
||||
agent AI coding agent usage data
|
||||
agent AI coding agents, their usage, and subscription accounts
|
||||
audio Audio input and output controls
|
||||
bar Omarchy shell bar layout and settings
|
||||
battery Battery status helpers
|
||||
|
||||
+11
-1
@@ -36,10 +36,20 @@ There are terminal shortcuts too: `a` runs the default agent inline in the curre
|
||||
|
||||
### The agents panel
|
||||
|
||||
The top bar grows an agents icon the first time Omarchy finds AI coding usage on the machine (and stays out of the way until then). The panel behind it tracks every subscription in one place: your plan, the percentage used of the 5-hour session and weekly limits (or the remaining prepaid balance), and token usage by day and by model. Claude Code, Codex, and Fireworks are covered out of the box.
|
||||
The top bar has an agents icon. Before you've set up an agent, its panel walks you through signing in to Claude, Codex, or Grok, and the first one becomes your default agent. After that, the panel puts every subscription on one page: each agent and account with its plan and the percentage used of its 5-hour session and weekly limits (or the remaining prepaid balance), under a line that rotates through what you've used across all of them, like tokens this week and your most used model. Under _Make something_ it offers starter prompts for a new theme, plugin, or app that hand the task to your default agent. Apps are built the way Omarchy's own Hype, Monologue, and Omacut are, in C++ and Qt Quick, following the omarchy-app agent skill, and the compiler and Qt pieces they need come installed. Claude Code, Codex, and Fireworks are covered out of the box.
|
||||
|
||||
Left-click the bar icon for the panel, right-click to launch your default agent. The usage records behind it are regenerated every 15 minutes by `omarchy agent usage-update`, and the panel can even merge usage from your other machines via a synced folder. See the README under `$OMARCHY_PATH/shell/plugins/agents/` for the full settings.
|
||||
|
||||
### Several subscriptions
|
||||
|
||||
If you pay for more than one Claude or Codex plan — a personal Max and a work one, say — Omarchy can keep them all signed in and move between them. Add a subscription with the + in the agents panel, which walks you through it right there, or with `omarchy agent account add claude Work` (or `codex`). It installs the CLI if it's missing. The first account for a provider signs in through your normal browser, right where the CLI always looks. Every one after that signs in through a private window, so the account your browser is already signed in to doesn't get picked up by mistake; sign in there as the account you're adding, and close any private window you already had open first. Grok can be signed in the same way, but only one Grok account for now. Never log out of the CLI to do this: logging out revokes the saved sign-in, and that account would have to be added again.
|
||||
|
||||
One account per provider is active, and every new session starts as it: `cx`, `cy`, a plain `claude` or `codex`, and anything launched through `omarchy agent`. Switch with `omarchy agent account use work` (and back with `omarchy agent account use main`), or in the panel by pressing an account's number and then `Enter` (or clicking its Use button). Sessions that are already running stay on the account they started with. Conversation history, settings, skills, and plugins are shared across accounts, so `claude --continue` in the same folder picks up where you left off on the new account.
|
||||
|
||||
With more than one account, the panel shows each account's limits side by side, with the active one marked. By default Omarchy notifies you when the active account passes 95% of a limit, and clicking the notification switches to the account with the most headroom. Hover an account's line in the panel and click the Autoswitch that appears beside Use (or press `m`), or run `omarchy agent account mode auto`, to have it switch on its own instead; while it's on, Autoswitch takes Use's place, and clicking it again goes back to notifying. Add a number to change the threshold, as in `omarchy agent account mode auto 90`. If every account is over the threshold, you'll hear about it once, along with which account frees up first. While an active account is within 15 points of its threshold (80% at the default), its limits are checked every three minutes rather than every 15.
|
||||
|
||||
`omarchy agent account list` shows every account and its latest limits, `omarchy agent account remove work` forgets one, and `omarchy agent account rename primary Hey` renames one (or click an account's name in the panel). These commands work on your default agent's accounts; to reach the other provider, name it first, as in `omarchy agent account use codex side`. The login you already had in `~/.claude` or `~/.codex` is the _Main_ account and stays where it is. An account left unused for more than about eight hours shows its last known limits until a session runs in it again. MCP servers you had when an account was added come along to it, but ones you add later with `claude mcp add` only reach the account you added them in. Claude Desktop and editors that start `claude` themselves don't follow the switch.
|
||||
|
||||
### Crash diagnosis
|
||||
|
||||
Omarchy watches systemd-coredump for process crashes. When something segfaults, you'll get a "Process crashed" notification — click it, and the crash is handed to your default agent along with Omarchy's diagnose-crash skill, which walks the agent through establishing the facts from the core dump and deciding whether the crash is worth reporting upstream. You can also run it by hand against any PID from `coredumpctl list` with `omarchy agent crash <pid>`.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
echo "Install the compiler and Qt pieces for building Omarchy-style apps"
|
||||
|
||||
# The omarchy-app agent skill builds apps the way Hype, Monologue, and Omacut
|
||||
# are built: C++ and Qt Quick, compiled with qmake6 and make, with SVG icons,
|
||||
# audio and video through Qt Multimedia, and heavy media work through ffmpeg.
|
||||
# Qt arrived only as a dependency of those apps, and the compiler not at all.
|
||||
omarchy-pkg-add base-devel qt6-base qt6-declarative qt6-multimedia qt6-svg qt6-wayland ffmpeg
|
||||
@@ -0,0 +1,28 @@
|
||||
echo "Link the omarchy-app agent skill for building apps"
|
||||
|
||||
# omarchy-provision-user links every skill, but only once per user, so
|
||||
# existing installs get the new one here, in the same places. A skill of the
|
||||
# user's own by that name is left where it is.
|
||||
skill="$OMARCHY_PATH/default/agents/skills/omarchy-app"
|
||||
|
||||
link_skill() {
|
||||
mkdir -p "$1"
|
||||
if [[ -e $1/omarchy-app && ! -L $1/omarchy-app ]]; then
|
||||
echo "Leaving your own $1/omarchy-app in place"
|
||||
else
|
||||
ln -sfn "$skill" "$1/omarchy-app"
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ -d $skill ]]; then
|
||||
for skills_dir in ~/.agents/skills ~/.claude/skills ~/.codex/skills ~/.pi/agent/skills ~/.gemini/config/skills ~/.hermes/skills; do
|
||||
link_skill "$skills_dir"
|
||||
done
|
||||
|
||||
if [[ -d ~/.hermes/profiles ]]; then
|
||||
for profile in ~/.hermes/profiles/*/; do
|
||||
[[ -d $profile ]] || continue
|
||||
link_skill "$profile/skills"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
@@ -117,12 +117,32 @@ Item {
|
||||
property int refreshIntervalSec: Math.max(30, Number(setting("refreshIntervalSec", 900)))
|
||||
property string pendingUpdateKind: ""
|
||||
|
||||
// A fifteen-minute interval can't catch an account crossing its switch
|
||||
// threshold, so while any provider with several accounts has its active one
|
||||
// within 15 points of that threshold (80% at the default 95%), the limits
|
||||
// are checked every three minutes. Those runs
|
||||
// reuse the transcript scans; only the limits probes are new, and any more
|
||||
// often than this Anthropic starts refusing them.
|
||||
readonly property bool nearLimit: {
|
||||
var rev = dataRevision
|
||||
for (var i = 0; i < agents.length; i++) {
|
||||
var record = agents[i] ? agents[i].record : null
|
||||
if (!record || !Array.isArray(record.accounts) || record.accounts.length < 2) continue
|
||||
var limits = Array.isArray(record.limits) ? record.limits : []
|
||||
var threshold = Number(record.accountSwitch && record.accountSwitch.threshold || 95)
|
||||
var from = Math.min(0.8, (threshold - 15) / 100)
|
||||
for (var j = 0; j < limits.length; j++)
|
||||
if (Number(limits[j] && limits[j].percent) >= from) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: root.refreshIntervalSec * 1000
|
||||
interval: root.nearLimit ? Math.min(180, root.refreshIntervalSec) * 1000 : root.refreshIntervalSec * 1000
|
||||
running: true
|
||||
repeat: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: root.runUpdate("normal")
|
||||
onTriggered: root.runUpdate(root.nearLimit ? "limits" : "normal")
|
||||
}
|
||||
|
||||
Process {
|
||||
@@ -220,7 +240,7 @@ Item {
|
||||
return numberValue(p.totalPrompts) > 0 || numberValue(p.totalSessions) > 0
|
||||
|| numberValue(p.activeDays) > 0 || numberValue(p.todayPrompts) > 0
|
||||
|| numberValue(p.todaySessions) > 0 || (p.limits && p.limits.length > 0)
|
||||
|| !!p.balance
|
||||
|| (p.accounts && p.accounts.length > 0) || !!p.balance
|
||||
}
|
||||
|
||||
// A prepaid agent's credit ledger. Like rate limits, the balance is
|
||||
@@ -254,7 +274,14 @@ Item {
|
||||
// Rate limits and balances stay per-account and are never merged
|
||||
// across devices.
|
||||
limits: Array.isArray(record.limits) ? record.limits : [],
|
||||
limitsStale: record.limitsStale === true,
|
||||
limitsFetchedAt: numberValue(record.limitsFetchedAt),
|
||||
tierLabel: String(record.tierLabel || ""),
|
||||
// Every subscription account's own limits, once there's more than one.
|
||||
accounts: Array.isArray(record.accounts) ? record.accounts : [],
|
||||
accountSwitch: record.accountSwitch || ({ mode: "manual", threshold: 95 }),
|
||||
// Codex's free full resets of its rate limits, when it has any.
|
||||
resetCredits: record.resetCredits || null,
|
||||
balance: balanceValue(record.balance),
|
||||
|
||||
todayPrompts: synced ? numberValue(stats.todayPrompts) : numberValue(record.todayPrompts),
|
||||
|
||||
+1504
-571
File diff suppressed because it is too large.
Load diff
@@ -9,31 +9,49 @@ cross-device aggregation); `Agent.qml` is the per-record file watcher.
|
||||
|
||||
## Panel
|
||||
|
||||
- **Hero** — the mark, the tool, and the plan it runs on ("Max 20x", "Pro").
|
||||
Auth and endpoint problems replace the plan line and repeat in a card.
|
||||
- **Subscription switch** — one chip per enabled agent (`h`/`l` or click).
|
||||
It appears only when more than one agent is enabled.
|
||||
- **Limits** — the percentage of each allowance used, a matching meter, and
|
||||
the time until the session or weekly window resets.
|
||||
- **Balance** — prepaid agents report a credit ledger instead of limits:
|
||||
remaining credit, a fuel-gauge meter that drains toward empty, and
|
||||
Every subscription on one page, limits first.
|
||||
|
||||
- **Hero** — the agents robot, and a line that rotates through what the
|
||||
token counts add up to across every agent: tokens this week and today, the
|
||||
most used model, the busiest day, and today's prompts and sessions. Its
|
||||
corner has + to add a subscription and >_ to start the default agent.
|
||||
- **One section per agent** — its mark, name, and plan, then a compact line
|
||||
per limit window: its meter and the time until it resets (the exact percentage
|
||||
on hover). A model-scoped allowance on the same clock (Claude's Fable weekly
|
||||
limit) is a tick on that window's meter rather than a line of its own; the
|
||||
row's tooltip names it. Sign-in and endpoint trouble shows under the name in the urgent
|
||||
color. Limits kept from an earlier check after a failed one dim, and their
|
||||
tooltip says how old they are.
|
||||
- **Accounts** — an agent with more than one subscription account (see
|
||||
`omarchy agent account`) lists each: name and plan on one line (the email on hover), and its own limit
|
||||
lines. An _ACTIVE_ label marks the account new sessions start as; the
|
||||
others get a _Use_ link. Hovering the line also reveals Autoswitch, which
|
||||
moves new sessions over on their own once the active account reaches its
|
||||
threshold; while it's on it stands in for Use, which shows only when you're
|
||||
on the line, and clicking it again goes back to notifying. Click a name to
|
||||
rename the account in place.
|
||||
- **Balance** — prepaid agents show a credit ledger instead of limits: a
|
||||
fuel-gauge meter that drains toward empty, the remaining credit, and
|
||||
funded-versus-spent detail.
|
||||
- **Tokens by day** — one row per day for the last week: day, bar, tokens, with today
|
||||
bolded at the bottom. Hover today for its prompt and session count.
|
||||
- **Tokens by model** — tokens per model with the bar behind each row scaled
|
||||
to the heaviest model,
|
||||
the same way the weekly chart scales to its busiest day. Hover for the
|
||||
input / output / cache split.
|
||||
- **Make something cool** — starter prompts (a new theme, plugin, or app) that
|
||||
start the default agent on the task through `omarchy agent prompt`.
|
||||
- **Adding a subscription** — the + in the hero's corner swaps the page for
|
||||
Claude Code, Codex, and Grok as large marks, three across, with the first
|
||||
one focused, and the hero's line reads Add an account. The + becomes the
|
||||
X that goes back. An agent that can't be added is dimmed and says why on
|
||||
hover. A further account asks for a name first, and Enter signs it in. The
|
||||
panel then runs `omarchy-agent-account-add --events` and follows it: the
|
||||
status, the code Grok asks you to confirm in the browser, a field to paste
|
||||
Claude's code back if its page shows one instead of finishing, and a link to
|
||||
reopen the sign-in page. Esc or the X stops the login. The browser taking focus may close the panel; the sign-in
|
||||
carries on and its result arrives as a notification.
|
||||
|
||||
A subscription appears only when it is enabled in settings and has actually
|
||||
recorded usage — on this machine or on a synced one. With one such agent
|
||||
there is no switch row at all; with none, the module leaves the bar entirely
|
||||
rather than sitting there with nothing to say. A CLI installed mid-session
|
||||
shows up at the next refresh, so nothing polls the disk waiting for it.
|
||||
|
||||
That self-hiding is why the widget ships in the default bar layout: a machine
|
||||
that has never run an AI coding agent draws nothing, and the icon arrives on
|
||||
its own the first time a scan finds usage. Drop it with
|
||||
The icon is always in the bar. On a machine with no agent yet, the panel is
|
||||
the blank slate for setting one up: it opens on the same choice of Claude,
|
||||
Codex, or Grok, and the first agent signed in becomes the default agent if
|
||||
none was picked. An agent appears once it is enabled in settings and has
|
||||
recorded usage, on this machine or a synced one; a CLI installed
|
||||
mid-session shows up at the next refresh. Drop the widget with
|
||||
`omarchy plugin disable omarchy.agents`.
|
||||
|
||||
## Data
|
||||
@@ -56,6 +74,19 @@ light surfaces — and the bar glyph stands in when there is none.
|
||||
| `codex` | The Codex app-server RPC | native Codex CLI session files (plus pi and opencode 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>.json` registers
|
||||
more than one account, the `claude` and `codex` records also carry
|
||||
`accounts: [{ id, label, email, plan, active, limits, stale, usageStatusText,
|
||||
authHelpText }]`, each account probed with its own sign-in (Claude caches each
|
||||
account's limits separately; Codex runs one app-server per account home), and
|
||||
`accountSwitch: { mode, threshold }`. The record's top-level `limits` and
|
||||
`tierLabel` keep describing the active account, and local stats stay one set,
|
||||
since every account shares the primary home's history. After each run,
|
||||
`omarchy-agent-usage-update` hands the fresh limits to
|
||||
`omarchy-agent-account-state autoswitch`, which notifies or switches when the
|
||||
active account crosses its threshold, and re-collects the record if the active
|
||||
account changed.
|
||||
|
||||
Claude limits need a signed-in CLI; without credentials the panel says so and
|
||||
falls back to local stats only. A non-default Claude directory is honored via
|
||||
`CLAUDE_CONFIG_DIR`, Codex via `CODEX_HOME`. Fireworks reads
|
||||
@@ -94,10 +125,21 @@ only adds the meter and the spent-of-funded line under the real figure.
|
||||
|
||||
## Interactions
|
||||
|
||||
- Bar icon: left = panel, right = launch agent, middle = next subscription.
|
||||
- Panel: `h`/`l` switch subscription, `j`/`k` scroll, `r` or Enter refresh,
|
||||
Tab moves to the neighboring bar panel, Esc closes.
|
||||
- IPC: `omarchy-shell omarchy.agents <open|close|toggle|refresh|next>`.
|
||||
- Bar icon: left = panel, right = launch agent, middle = refresh. It turns
|
||||
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
|
||||
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
|
||||
active (picking alone never switches). `m` toggles automatic switching for
|
||||
the picked account's agent. While an agent
|
||||
with several accounts has its active one within 15 points of its switch
|
||||
threshold (80% at the default), the limits refresh every three minutes.
|
||||
- IPC: `omarchy-shell omarchy.agents <open|close|toggle|refresh>`.
|
||||
|
||||
## Settings
|
||||
|
||||
|
||||
@@ -138,6 +138,15 @@ output=$("$CLI" hw asus)
|
||||
assert_output_contains "partial hardware prefix renders matching commands" "$output" "omarchy hw asus rog"
|
||||
assert_output_contains "partial hardware prefix includes nested match" "$output" "omarchy hw asus zenbook ux5406aa"
|
||||
|
||||
# omarchy-agent takes only flags, so a word after it names the account
|
||||
# commands rather than reaching the agent launcher.
|
||||
output=$("$CLI" agent account)
|
||||
assert_output_contains "a family under a flags-only command renders its commands" "$output" "omarchy agent account use"
|
||||
output=$("$CLI" agent account --help)
|
||||
assert_output_contains "a family under a flags-only command renders its help" "$output" "omarchy agent account add"
|
||||
output=$("$CLI" agent --help)
|
||||
assert_output_contains "the flags-only command keeps its own help" "$output" "omarchy agent [--inline] [--pick]"
|
||||
|
||||
output=$("$CLI" menu --help)
|
||||
assert_output_contains "menu group includes share fallback route" "$output" "omarchy menu share"
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
require_command jq
|
||||
require_command python3
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
mkdir -p "$test_tmp/bin" "$test_tmp/home/.claude"
|
||||
notifications="$test_tmp/notifications"
|
||||
cat >"$test_tmp/bin/omarchy-notification-send" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"$OMARCHY_TEST_NOTIFICATIONS"
|
||||
SH
|
||||
chmod +x "$test_tmp/bin/omarchy-notification-send"
|
||||
|
||||
export HOME="$test_tmp/home"
|
||||
export XDG_STATE_HOME="$test_tmp/state"
|
||||
export PATH="$test_tmp/bin:$ROOT/bin:$PATH"
|
||||
export OMARCHY_TEST_NOTIFICATIONS="$notifications"
|
||||
|
||||
accounts="$XDG_STATE_HOME/omarchy/agents/accounts"
|
||||
usage="$XDG_STATE_HOME/omarchy/agents/usage"
|
||||
mkdir -p "$accounts/claude/work" "$usage"
|
||||
|
||||
soon=$(python3 -c 'import datetime as dt; print((dt.datetime.now(dt.timezone.utc) + dt.timedelta(minutes=72)).isoformat())')
|
||||
later=$(python3 -c 'import datetime as dt; print((dt.datetime.now(dt.timezone.utc) + dt.timedelta(hours=4)).isoformat())')
|
||||
gone=$(python3 -c 'import datetime as dt; print((dt.datetime.now(dt.timezone.utc) - dt.timedelta(minutes=5)).isoformat())')
|
||||
|
||||
registry() {
|
||||
cat >"$accounts/claude.json" <<JSON
|
||||
{
|
||||
"active": "$1",
|
||||
"switch": "$2",
|
||||
"threshold": ${3:-95},
|
||||
"alert": "",
|
||||
"accounts": [
|
||||
{"id": "main", "label": "Main", "home": "", "primary": true},
|
||||
{"id": "work", "label": "Work", "home": "$accounts/claude/work", "primary": false}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
}
|
||||
|
||||
# Session percent and reset for each account, as the usage record carries them.
|
||||
record() {
|
||||
jq -nc --argjson main "$1" --arg mainReset "$2" --argjson work "$3" --arg workReset "$4" '{
|
||||
id: "claude",
|
||||
accounts: [
|
||||
{id: "main", limits: [{label: "Session (5-hour)", percent: $main, resetsAt: $mainReset}]},
|
||||
{id: "work", limits: [{label: "Session (5-hour)", percent: $work, resetsAt: $workReset}]}
|
||||
]
|
||||
}' >"$usage/claude.json"
|
||||
}
|
||||
|
||||
autoswitch() {
|
||||
: >"$notifications"
|
||||
omarchy-agent-account-state autoswitch claude
|
||||
}
|
||||
|
||||
active() {
|
||||
jq -r .active "$accounts/claude.json"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- manual mode
|
||||
|
||||
registry main manual
|
||||
record 0.97 "$soon" 0.12 "$later"
|
||||
[[ -z $(autoswitch) && $(active) == "main" ]] || fail "manual mode never moves the active account"
|
||||
grep -q "Main is at 97% of a Claude limit Work is at 12%. Click to switch new sessions to it. --exec omarchy-agent-account-use claude work" "$notifications" ||
|
||||
fail "manual mode offers the switch as the notification's click" "$(cat "$notifications")"
|
||||
pass "manual mode notifies with a one-click switch"
|
||||
|
||||
autoswitch >/dev/null
|
||||
[[ ! -s $notifications ]] || fail "manual mode says it once" "$(cat "$notifications")"
|
||||
pass "manual mode doesn't repeat itself"
|
||||
|
||||
# ------------------------------------------------------------------ auto mode
|
||||
|
||||
registry main auto
|
||||
record 0.949 "$soon" 0.12 "$later"
|
||||
[[ -z $(autoswitch) && ! -s $notifications ]] || fail "an account under the threshold stays put"
|
||||
pass "an account under the threshold stays put"
|
||||
|
||||
record 0.96 "$soon" 0.12 "$later"
|
||||
[[ $(autoswitch) == "claude" && $(active) == "work" ]] || fail "auto mode moves to the account with headroom"
|
||||
grep -q "New Claude sessions now use Work (12%). Running sessions stay on Main." "$notifications" ||
|
||||
fail "auto mode says where new sessions went" "$(cat "$notifications")"
|
||||
pass "auto mode switches at the threshold"
|
||||
|
||||
# Main is still at 96%, but only the active account crossing starts anything.
|
||||
record 0.96 "$soon" 0.30 "$later"
|
||||
[[ -z $(autoswitch) && $(active) == "work" && ! -s $notifications ]] || fail "auto mode never flaps back"
|
||||
pass "auto mode leaves a healthy active account alone"
|
||||
|
||||
# A stale 99% whose window already reset is an untouched allowance.
|
||||
registry main auto
|
||||
record 0.97 "$soon" 0.99 "$gone"
|
||||
[[ $(autoswitch) == "claude" && $(active) == "work" ]] || fail "a window past its reset counts as empty"
|
||||
pass "a window past its reset counts as empty"
|
||||
|
||||
# ------------------------------------------------------------------- exhausted
|
||||
|
||||
registry main auto
|
||||
record 0.97 "$later" 0.98 "$soon"
|
||||
[[ -z $(autoswitch) && $(active) == "main" ]] || fail "with nowhere to go, auto mode stays put"
|
||||
grep -q "All Claude accounts are over 95% Staying on Main. Work resets in 1h 1[12]m." "$notifications" ||
|
||||
fail "exhaustion names the account that frees up first" "$(cat "$notifications")"
|
||||
autoswitch >/dev/null
|
||||
[[ ! -s $notifications ]] || fail "exhaustion is said once"
|
||||
pass "all accounts over the threshold is said once"
|
||||
|
||||
# Work's session is nearly empty and resets soon, but its full weekly window
|
||||
# is what holds it over the threshold, so that reset is when it frees up.
|
||||
registry main auto
|
||||
jq -nc --arg later "$later" --arg soon "$soon" --arg week "$(python3 -c 'import datetime as dt; print((dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=6)).isoformat())')" '{
|
||||
id: "claude",
|
||||
accounts: [
|
||||
{id: "main", limits: [{label: "Session (5-hour)", percent: 0.97, resetsAt: $later}]},
|
||||
{id: "work", limits: [{label: "Session (5-hour)", percent: 0.10, resetsAt: $soon}, {label: "Weekly (7-day)", percent: 1.0, resetsAt: $week}]}
|
||||
]
|
||||
}' >"$usage/claude.json"
|
||||
autoswitch >/dev/null
|
||||
grep -q "Main resets in 3h 5[89]m." "$notifications" || fail "exhaustion ignores a reset that doesn't free the account" "$(cat "$notifications")"
|
||||
pass "exhaustion names when an account actually frees up"
|
||||
|
||||
record 0.20 "$later" 0.98 "$soon"
|
||||
autoswitch >/dev/null
|
||||
[[ $(jq -r .alert "$accounts/claude.json") == "" ]] || fail "dropping back under the threshold re-arms the alert"
|
||||
pass "dropping back under the threshold re-arms the alert"
|
||||
|
||||
registry main auto 80
|
||||
record 0.85 "$soon" 0.12 "$later"
|
||||
[[ $(autoswitch) == "claude" ]] || fail "the threshold is configurable"
|
||||
pass "the threshold is configurable"
|
||||
|
||||
# An account nobody is signed in to is never switched to, however much room
|
||||
# its old numbers show.
|
||||
registry main auto
|
||||
record 0.97 "$soon" 0.10 "$later"
|
||||
jq '.accounts[1].usageStatusText = "Waiting for auth"' "$usage/claude.json" >"$test_tmp/record.json"
|
||||
mv "$test_tmp/record.json" "$usage/claude.json"
|
||||
[[ -z $(autoswitch) && $(active) == "main" ]] || fail "a signed-out account is never switched to"
|
||||
pass "a signed-out account is never switched to"
|
||||
|
||||
# With another account unknown, nobody can say every account is over.
|
||||
registry main auto
|
||||
record 0.97 "$later" 0.98 "$soon"
|
||||
jq '.accounts[1].usageStatusText = "Waiting for auth"' "$usage/claude.json" >"$test_tmp/record.json"
|
||||
mv "$test_tmp/record.json" "$usage/claude.json"
|
||||
autoswitch >/dev/null
|
||||
[[ ! -s $notifications ]] || fail "an unknown account keeps exhaustion unsaid" "$(cat "$notifications")"
|
||||
pass "exhaustion is only said when every account is known to be over"
|
||||
|
||||
# Numbers kept from an earlier check may be out of date, so an account checked
|
||||
# just now wins over a stale one showing more room; a stale one still counts
|
||||
# when it's the only place left to go.
|
||||
mkdir -p "$accounts/claude/side"
|
||||
cat >"$accounts/claude.json" <<JSON
|
||||
{
|
||||
"active": "main",
|
||||
"switch": "auto",
|
||||
"threshold": 95,
|
||||
"alert": "",
|
||||
"accounts": [
|
||||
{"id": "main", "label": "Main", "home": "", "primary": true},
|
||||
{"id": "work", "label": "Work", "home": "$accounts/claude/work", "primary": false},
|
||||
{"id": "side", "label": "Side", "home": "$accounts/claude/side", "primary": false}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
jq -nc --arg later "$later" '{
|
||||
id: "claude",
|
||||
accounts: [
|
||||
{id: "main", limits: [{label: "Session (5-hour)", percent: 0.97, resetsAt: $later}]},
|
||||
{id: "work", stale: true, limits: [{label: "Session (5-hour)", percent: 0.10, resetsAt: $later}]},
|
||||
{id: "side", stale: false, limits: [{label: "Session (5-hour)", percent: 0.40, resetsAt: $later}]}
|
||||
]
|
||||
}' >"$usage/claude.json"
|
||||
autoswitch >/dev/null
|
||||
[[ $(active) == "side" ]] || fail "an account checked just now wins over a stale one" "$(active)"
|
||||
pass "an account checked just now wins over a stale one"
|
||||
|
||||
# Room comes first: a fresh account within 15 points of the threshold loses to
|
||||
# a stale one with plenty left.
|
||||
jq '.active = "main" | .alert = ""' "$accounts/claude.json" >"$test_tmp/registry.json"
|
||||
mv "$test_tmp/registry.json" "$accounts/claude.json"
|
||||
jq -nc --arg later "$later" '{
|
||||
id: "claude",
|
||||
accounts: [
|
||||
{id: "main", limits: [{label: "Session (5-hour)", percent: 0.97, resetsAt: $later}]},
|
||||
{id: "work", stale: true, limits: [{label: "Session (5-hour)", percent: 0.10, resetsAt: $later}]},
|
||||
{id: "side", stale: false, limits: [{label: "Session (5-hour)", percent: 0.94, resetsAt: $later}]}
|
||||
]
|
||||
}' >"$usage/claude.json"
|
||||
autoswitch >/dev/null
|
||||
[[ $(active) == "work" ]] || fail "a stale account with room wins over a fresh one near the limit" "$(active)"
|
||||
pass "a stale account with room wins over a fresh one near the limit"
|
||||
@@ -0,0 +1,402 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
require_command jq
|
||||
require_command python3
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
mock_bin="$test_tmp/bin"
|
||||
notifications="$test_tmp/notifications"
|
||||
mkdir -p "$mock_bin" "$test_tmp/home/.claude" "$test_tmp/home/.codex"
|
||||
|
||||
cat >"$mock_bin/omarchy-notification-send" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"$OMARCHY_TEST_NOTIFICATIONS"
|
||||
SH
|
||||
|
||||
# The CLIs report which home they were started in, and a login writes the
|
||||
# identity a real one would into whichever home it was given.
|
||||
cat >"$mock_bin/claude" <<'SH'
|
||||
#!/bin/bash
|
||||
if [[ ${1:-} == "auth" && ${2:-} == "login" ]]; then
|
||||
"${BROWSER:-omarchy-test-default-browser}" "https://claude.com/oauth/authorize"
|
||||
if [[ -n ${OMARCHY_TEST_LOGIN_HANGS:-} ]]; then
|
||||
echo $$ >"$OMARCHY_TEST_LOGIN_HANGS"
|
||||
exec sleep 30
|
||||
fi
|
||||
[[ -n ${OMARCHY_TEST_LOGIN_UUID:-} ]] || exit 1
|
||||
home=${CLAUDE_CONFIG_DIR:-$HOME/.claude}
|
||||
account_file="$home/.claude.json"
|
||||
[[ -z ${CLAUDE_CONFIG_DIR:-} ]] && account_file="$HOME/.claude.json"
|
||||
printf '{"oauthAccount":{"accountUuid":"%s","emailAddress":"%s","organizationName":"Work"}}\n' \
|
||||
"$OMARCHY_TEST_LOGIN_UUID" "$OMARCHY_TEST_LOGIN_EMAIL" >"$account_file"
|
||||
echo '{"claudeAiOauth":{"rateLimitTier":"default_claude_max_5x","subscriptionType":"max"}}' >"$home/.credentials.json"
|
||||
exit 0
|
||||
fi
|
||||
echo "claude home=${CLAUDE_CONFIG_DIR:-default} args=$*"
|
||||
SH
|
||||
|
||||
cat >"$mock_bin/codex" <<'SH'
|
||||
#!/bin/bash
|
||||
if [[ ${1:-} == "login" ]]; then
|
||||
"${BROWSER:-omarchy-test-default-browser}" "https://auth.openai.com/oauth/authorize"
|
||||
claims=$(printf '{"email":"%s","https://api.openai.com/auth":{"chatgpt_plan_type":"pro"}}' "$OMARCHY_TEST_LOGIN_EMAIL" | base64 -w0 | tr '+/' '-_' | tr -d '=')
|
||||
printf '{"auth_mode":"chatgpt","tokens":{"account_id":"%s","id_token":"h.%s.s"}}\n' "$OMARCHY_TEST_LOGIN_UUID" "$claims" >"${CODEX_HOME:-$HOME/.codex}/auth.json"
|
||||
exit 0
|
||||
fi
|
||||
echo "codex home=${CODEX_HOME:-default} args=$*"
|
||||
SH
|
||||
|
||||
cat >"$mock_bin/grok" <<'SH'
|
||||
#!/bin/bash
|
||||
if [[ ${1:-} == "login" ]]; then
|
||||
"${BROWSER:-omarchy-test-default-browser}" "https://auth.x.ai/oauth/authorize"
|
||||
mkdir -p "${GROK_HOME:-$HOME/.grok}"
|
||||
echo '{"token":"t"}' >"${GROK_HOME:-$HOME/.grok}/auth.json"
|
||||
fi
|
||||
SH
|
||||
|
||||
cat >"$mock_bin/omarchy-test-default-browser" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'default %s\n' "$*" >>"$OMARCHY_TEST_BROWSER_LOG"
|
||||
SH
|
||||
|
||||
cat >"$mock_bin/omarchy-agent-usage-update" <<'SH'
|
||||
#!/bin/bash
|
||||
SH
|
||||
|
||||
cat >"$mock_bin/omarchy-default-agent" <<'SH'
|
||||
#!/bin/bash
|
||||
echo "${OMARCHY_TEST_DEFAULT_AGENT-claude}"
|
||||
SH
|
||||
|
||||
cat >"$mock_bin/omarchy-launch-browser" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"$OMARCHY_TEST_BROWSER_LOG"
|
||||
SH
|
||||
|
||||
chmod +x "$mock_bin"/*
|
||||
|
||||
export HOME="$test_tmp/home"
|
||||
export XDG_STATE_HOME="$test_tmp/state"
|
||||
export PATH="$mock_bin:$ROOT/bin:$PATH"
|
||||
export OMARCHY_PATH="$ROOT"
|
||||
export OMARCHY_TEST_NOTIFICATIONS="$notifications"
|
||||
export OMARCHY_TEST_BROWSER_LOG="$test_tmp/browser"
|
||||
unset CLAUDE_CONFIG_DIR CODEX_HOME GROK_HOME BROWSER
|
||||
|
||||
accounts="$XDG_STATE_HOME/omarchy/agents/accounts"
|
||||
|
||||
echo '{"oauthAccount":{"accountUuid":"u-main","emailAddress":"me@example.com","organizationName":"Me"},"mcpServers":{"docs":{"command":"docs-mcp"}}}' >"$HOME/.claude.json"
|
||||
echo '{"claudeAiOauth":{"rateLimitTier":"default_claude_max_20x","subscriptionType":"max"}}' >"$HOME/.claude/.credentials.json"
|
||||
echo '{"theme":"custom:omarchy"}' >"$HOME/.claude/settings.json"
|
||||
|
||||
# ---------------------------------------------------------------- single account
|
||||
|
||||
[[ -z $(omarchy-agent-account-home claude) ]] || fail "a machine with one account routes nowhere"
|
||||
[[ ! -e $accounts/claude.json ]] || fail "reading the active home never creates a registry"
|
||||
pass "a machine with one account launches exactly as before"
|
||||
|
||||
list=$(omarchy-agent-account-list claude --json)
|
||||
[[ $(jq -c '.[0].accounts | map({id, label, plan, email, active, primary})' <<<"$list") == '[{"id":"main","label":"Main","plan":"Max 20x","email":"me@example.com","active":true,"primary":true}]' ]] ||
|
||||
fail "the existing login is listed as the primary account" "$list"
|
||||
pass "the existing login is listed as the primary account"
|
||||
|
||||
# ------------------------------------------------------------------------- add
|
||||
|
||||
OMARCHY_TEST_LOGIN_UUID=u-work OMARCHY_TEST_LOGIN_EMAIL=work@example.com \
|
||||
omarchy-agent-account-add claude Work </dev/null >"$test_tmp/add-output"
|
||||
grep -q "Added Work (work@example.com)" "$test_tmp/add-output" || fail "adding an account reports who signed in" "$(cat "$test_tmp/add-output")"
|
||||
grep -q "private window that opens" "$test_tmp/add-output" || fail "adding an account says to sign in within the private window"
|
||||
[[ $(head -1 "$OMARCHY_TEST_BROWSER_LOG") == "--private https://claude.com/oauth/authorize" ]] ||
|
||||
fail "a Claude login opens in a private window" "$(cat "$OMARCHY_TEST_BROWSER_LOG")"
|
||||
pass "adding an account signs in through the CLI's own login"
|
||||
|
||||
work="$accounts/claude/work"
|
||||
[[ -f $work/.credentials.json && ! -L $work/.credentials.json ]] || fail "an added account keeps its own credentials"
|
||||
[[ $(readlink "$work/projects") == "$HOME/.claude/projects" ]] || fail "an added account shares conversation history with the primary"
|
||||
[[ $(readlink "$work/settings.json") == "$HOME/.claude/settings.json" ]] || fail "an added account shares settings with the primary"
|
||||
[[ $(readlink "$work/CLAUDE.md") == "$HOME/.claude/CLAUDE.md" && ! -e $HOME/.claude/CLAUDE.md ]] ||
|
||||
fail "a shared file the primary doesn't have yet is linked, so it's written there when it is"
|
||||
[[ $(jq -c .mcpServers "$work/.claude.json") == '{"docs":{"command":"docs-mcp"}}' ]] || fail "an added account carries the primary's MCP servers"
|
||||
[[ $(stat -c %a "$work") == 700 && $(stat -c %a "$accounts/claude.json") == 600 ]] || fail "account homes and the registry are private"
|
||||
pass "an added account shares everything but its login with the primary"
|
||||
|
||||
if OMARCHY_TEST_LOGIN_UUID=u-work OMARCHY_TEST_LOGIN_EMAIL=work@example.com \
|
||||
omarchy-agent-account-add claude Again </dev/null >"$test_tmp/dup-output" 2>&1; then
|
||||
fail "adding the same account twice fails"
|
||||
fi
|
||||
grep -q "That's Work" "$test_tmp/dup-output" || fail "a duplicate login names the account it already is" "$(cat "$test_tmp/dup-output")"
|
||||
[[ -z $(ls -A "$accounts/claude/.pending") ]] || fail "a duplicate login leaves no scratch home behind"
|
||||
pass "adding an account that's already there is refused"
|
||||
|
||||
# Main was signed in to another account by hand since the registry was made;
|
||||
# adding that login again is still the same subscription.
|
||||
jq '.oauthAccount.accountUuid = "u-relogged"' "$HOME/.claude.json" >"$test_tmp/relogged.json"
|
||||
cp "$HOME/.claude.json" "$test_tmp/original.json"
|
||||
mv "$test_tmp/relogged.json" "$HOME/.claude.json"
|
||||
if OMARCHY_TEST_LOGIN_UUID=u-relogged OMARCHY_TEST_LOGIN_EMAIL=me@example.com \
|
||||
omarchy-agent-account-add claude Twin </dev/null >"$test_tmp/twin-output" 2>&1; then
|
||||
fail "a login matching Main's current sign-in is refused"
|
||||
fi
|
||||
grep -q "That's Main" "$test_tmp/twin-output" || fail "a login matching Main's current sign-in names Main" "$(cat "$test_tmp/twin-output")"
|
||||
mv "$test_tmp/original.json" "$HOME/.claude.json"
|
||||
pass "duplicates are judged by who each home is signed in as now"
|
||||
|
||||
if OMARCHY_TEST_LOGIN_UUID="" omarchy-agent-account-add claude Nope </dev/null >/dev/null 2>&1; then
|
||||
fail "an abandoned login adds nothing"
|
||||
fi
|
||||
[[ $(omarchy-agent-account-list claude --json | jq '.[0].accounts | length') == 2 ]] || fail "an abandoned login adds nothing"
|
||||
pass "an abandoned login adds nothing"
|
||||
|
||||
OMARCHY_TEST_LOGIN_UUID=u-next OMARCHY_TEST_LOGIN_EMAIL=next@example.com \
|
||||
omarchy-agent-account-add claude Next </dev/null >/dev/null
|
||||
[[ $(omarchy-agent-account-list claude --json | jq -r '.[0].accounts[] | select(.label == "Next") | .id') == "next-2" ]] ||
|
||||
fail "an account label can't take an id that routing already answers to"
|
||||
omarchy-agent-account-remove claude next-2 </dev/null >/dev/null
|
||||
pass "account ids stay clear of routing keywords"
|
||||
|
||||
OMARCHY_TEST_LOGIN_UUID=acct-1 OMARCHY_TEST_LOGIN_EMAIL=me@example.com \
|
||||
omarchy-agent-account-add codex </dev/null >"$test_tmp/first-codex"
|
||||
[[ -f $HOME/.codex/auth.json && ! -d $accounts/codex ]] || fail "the first Codex account signs in to ~/.codex itself"
|
||||
grep -qx "default https://auth.openai.com/oauth/authorize" "$OMARCHY_TEST_BROWSER_LOG" ||
|
||||
fail "the first Codex account signs in through the normal browser" "$(cat "$OMARCHY_TEST_BROWSER_LOG")"
|
||||
[[ ! -e $HOME/.config/omarchy/defaults/agent ]] || fail "a first sign-in leaves an existing default agent alone"
|
||||
pass "the first account of a provider signs in to its own home in the normal browser"
|
||||
|
||||
OMARCHY_TEST_LOGIN_UUID=acct-2 OMARCHY_TEST_LOGIN_EMAIL=side@example.com \
|
||||
omarchy-agent-account-add codex Side </dev/null >/dev/null
|
||||
[[ $(omarchy-agent-account-list codex --json | jq -c '.[0].accounts[1] | {id, email, plan}') == '{"id":"side","email":"side@example.com","plan":"Pro"}' ]] ||
|
||||
fail "a Codex account reads its identity from the login's token claims"
|
||||
[[ $(readlink "$accounts/codex/side/sessions") == "$HOME/.codex/sessions" ]] || fail "a Codex account shares sessions with the primary"
|
||||
grep -qx -- "--private https://auth.openai.com/oauth/authorize" "$OMARCHY_TEST_BROWSER_LOG" ||
|
||||
fail "a Codex login opens in a private window" "$(cat "$OMARCHY_TEST_BROWSER_LOG")"
|
||||
pass "Codex accounts are added the same way"
|
||||
|
||||
OMARCHY_TEST_DEFAULT_AGENT="" omarchy-agent-account-add grok </dev/null >/dev/null
|
||||
[[ $(cat "$HOME/.config/omarchy/defaults/agent") == "grok" ]] ||
|
||||
fail "the first agent signed in on a machine with no default becomes the default"
|
||||
[[ -s $HOME/.grok/auth.json ]] && grep -qx "default https://auth.x.ai/oauth/authorize" "$OMARCHY_TEST_BROWSER_LOG" ||
|
||||
fail "a first Grok sign-in lands in ~/.grok through the normal browser"
|
||||
if omarchy-agent-account-add grok Second </dev/null >"$test_tmp/grok-second" 2>&1; then
|
||||
fail "a second Grok account says it isn't supported yet"
|
||||
fi
|
||||
grep -q "isn't supported yet" "$test_tmp/grok-second" || fail "a second Grok account says why it stops" "$(cat "$test_tmp/grok-second")"
|
||||
pass "Grok signs in its first account"
|
||||
|
||||
# ---------------------------------------------------------------------- routing
|
||||
|
||||
omarchy-agent-account-use claude work >/dev/null
|
||||
[[ $(omarchy-agent-account-home claude) == "$work" ]] || fail "the active account's home is what launches use"
|
||||
grep -q "New Claude sessions now use Work (Max 5x)" "$notifications" || fail "switching says where new sessions go"
|
||||
pass "use makes an account active and says so"
|
||||
|
||||
source "$ROOT/default/bash/fns/agent-accounts"
|
||||
[[ $(claude --version) == "claude home=$work args=--version" ]] || fail "claude at a prompt starts as the active account"
|
||||
[[ $(CLAUDE_CONFIG_DIR=/elsewhere claude) == "claude home=/elsewhere args=" ]] || fail "an explicit CLAUDE_CONFIG_DIR wins over the active account"
|
||||
[[ $(codex) == "codex home=default args=" ]] || fail "codex stays on its primary until switched"
|
||||
pass "shell launches follow the active account"
|
||||
|
||||
[[ $(OMARCHY_TEST_DEFAULT_AGENT=claude omarchy-agent --inline) == "claude home=$work args=--permission-mode auto" ]] ||
|
||||
fail "omarchy-agent starts Claude as the active account"
|
||||
omarchy-agent-account-use codex side >/dev/null
|
||||
[[ $(OMARCHY_TEST_DEFAULT_AGENT=codex omarchy-agent --inline) == "codex home=$accounts/codex/side args=--approve-for-me" ]] ||
|
||||
fail "omarchy-agent starts Codex as the active account"
|
||||
pass "omarchy-agent follows the active account"
|
||||
|
||||
omarchy-agent-account-use claude next >/dev/null
|
||||
[[ -z $(omarchy-agent-account-home claude) ]] || fail "next cycles back to the primary"
|
||||
pass "next cycles through accounts"
|
||||
|
||||
# With Claude as the default agent, the provider can be left out.
|
||||
OMARCHY_TEST_DEFAULT_AGENT=claude omarchy-agent-account-use work >/dev/null
|
||||
[[ $(omarchy-agent-account-home claude) == "$work" ]] || fail "use without a provider picks the default agent's account"
|
||||
OMARCHY_TEST_DEFAULT_AGENT=claude omarchy-agent-account-use main >/dev/null
|
||||
[[ -z $(omarchy-agent-account-home claude) ]] || fail "use without a provider switches back to Main"
|
||||
OMARCHY_TEST_DEFAULT_AGENT=codex omarchy-agent-account-use main >/dev/null
|
||||
[[ -z $(omarchy-agent-account-home codex) ]] || fail "the default agent decides which provider a short use means"
|
||||
if OMARCHY_TEST_DEFAULT_AGENT=pi omarchy-agent-account-use work >/dev/null 2>&1; then
|
||||
fail "a default agent with no accounts still needs the provider named"
|
||||
fi
|
||||
pass "the provider defaults to your default agent"
|
||||
|
||||
# ----------------------------------------------------------------------- rename
|
||||
|
||||
omarchy-agent-account-use claude work >/dev/null
|
||||
omarchy-agent-account-rename claude work Day job >/dev/null
|
||||
[[ $(omarchy-agent-account-list claude --json | jq -c '.[0] | {active, renamed: (.accounts[] | select(.id == "day-job") | .label)}') == '{"active":"day-job","renamed":"Day job"}' ]] ||
|
||||
fail "renaming changes the label and the id it answers to, and the active account follows"
|
||||
[[ $(omarchy-agent-account-home claude) == "$work" ]] || fail "renaming leaves the account's home where running sessions expect it"
|
||||
OMARCHY_TEST_DEFAULT_AGENT=claude omarchy-agent-account-rename day-job Work >/dev/null
|
||||
[[ $(omarchy-agent-account-home claude) == "$work" ]] || fail "a renamed account can be renamed back"
|
||||
omarchy-agent-account-rename claude main Personal >/dev/null
|
||||
[[ $(omarchy-agent-account-list claude --json | jq -r '.[0].accounts[] | select(.primary) | "\(.id) \(.home)"') == "personal $HOME/.claude" ]] ||
|
||||
fail "the primary account can be renamed and keeps ~/.claude"
|
||||
omarchy-agent-account-rename claude personal Main >/dev/null
|
||||
# `primary` reaches the first login whatever it's called, as the manual says.
|
||||
omarchy-agent-account-rename claude primary Hey >/dev/null ||
|
||||
fail "primary names the primary account"
|
||||
omarchy-agent-account-rename claude primary Main >/dev/null
|
||||
[[ $(omarchy-agent-account-list claude --json | jq -r '.[0].accounts[] | select(.primary) | .id') == "main" ]] ||
|
||||
fail "primary still reaches the primary account after a rename"
|
||||
if omarchy-agent-account-rename claude work "" >/dev/null 2>&1; then
|
||||
fail "an account can't be renamed to nothing"
|
||||
fi
|
||||
pass "rename relabels an account without moving it"
|
||||
|
||||
# ------------------------------------------------------------ mode and remove
|
||||
|
||||
OMARCHY_TEST_DEFAULT_AGENT=claude omarchy-agent-account-mode auto 90 >/dev/null
|
||||
[[ $(jq -c '{switch, threshold}' "$accounts/claude.json") == '{"switch":"auto","threshold":90}' ]] || fail "mode sets switching and threshold"
|
||||
if omarchy-agent-account-mode claude sometimes >/dev/null 2>&1; then
|
||||
fail "mode refuses an unknown switch mode"
|
||||
fi
|
||||
pass "mode sets how switching happens"
|
||||
|
||||
if omarchy-agent-account-remove claude main </dev/null >/dev/null 2>&1; then
|
||||
fail "the primary account can't be removed"
|
||||
fi
|
||||
omarchy-agent-account-use claude work >/dev/null
|
||||
# A session still running in the account keeps its login until it quits.
|
||||
CLAUDE_CONFIG_DIR="$work" sleep 30 &
|
||||
session=$!
|
||||
if omarchy-agent-account-remove claude work </dev/null >/dev/null 2>"$test_tmp/in-use"; then
|
||||
fail "an account a running session uses can't be removed"
|
||||
fi
|
||||
[[ -d $work ]] && grep -q "Quit it first" "$test_tmp/in-use" || fail "removing an account in use leaves it and says why" "$(cat "$test_tmp/in-use")"
|
||||
kill "$session"; wait "$session" 2>/dev/null || true
|
||||
omarchy-agent-account-remove claude work </dev/null >/dev/null
|
||||
[[ ! -e $work && -d $HOME/.claude/projects && -f $HOME/.claude/settings.json ]] || fail "removing an account deletes its home and nothing it links to"
|
||||
[[ -z $(omarchy-agent-account-home claude) ]] || fail "removing the active account falls back to the primary"
|
||||
pass "remove forgets an added account without touching shared files"
|
||||
|
||||
# A registry that can't be saved leaves the new login pending, where the add
|
||||
# command cleans it up, rather than in a home nothing can manage.
|
||||
STATE="$ROOT/bin/omarchy-agent-account-state" python3 - <<'PY' || fail "a failed registration rolls the new home back to pending"
|
||||
import importlib.machinery, importlib.util, json, os, sys
|
||||
loader = importlib.machinery.SourceFileLoader("state", os.environ["STATE"])
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
state = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(state)
|
||||
|
||||
pending = state.begin("claude")
|
||||
(pending / ".claude.json").write_text(json.dumps({"oauthAccount": {"accountUuid": "u-stranded", "emailAddress": "stranded@example.com"}}))
|
||||
|
||||
def broken_save(provider, registry):
|
||||
raise OSError("disk full")
|
||||
state.save = broken_save
|
||||
|
||||
try:
|
||||
state.register("claude", "Stranded", pending)
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
sys.exit("register should fail when the registry can't be saved")
|
||||
assert pending.is_dir(), "the login goes back to pending"
|
||||
assert not (state.accounts_root() / "claude" / "stranded").exists(), "no home is left outside the registry"
|
||||
# The add command would remove it on exit; this test stands in for it.
|
||||
import shutil
|
||||
shutil.rmtree(pending)
|
||||
PY
|
||||
pass "a failed registration rolls the new home back to pending"
|
||||
|
||||
# ------------------------------------------------------------ panel add flow
|
||||
|
||||
[[ $(omarchy-agent-account-add --check) == $'claude additional\ncodex additional\ngrok unsupported' ]] ||
|
||||
fail "--check says what adding would mean for each provider" "$(omarchy-agent-account-add --check)"
|
||||
pass "--check says what adding would mean for each provider"
|
||||
|
||||
: >"$notifications"
|
||||
OMARCHY_TEST_LOGIN_UUID=u-events OMARCHY_TEST_LOGIN_EMAIL=events@example.com \
|
||||
omarchy-agent-account-add --events claude Events </dev/null >"$test_tmp/events-output"
|
||||
grep -qx "@@omarchy status Sign in as the account you're adding in the private window that opens." "$test_tmp/events-output" ||
|
||||
fail "--events reports progress as tagged lines" "$(cat "$test_tmp/events-output")"
|
||||
grep -qx "@@omarchy done Added Events (events@example.com)." "$test_tmp/events-output" ||
|
||||
fail "--events reports the result as a tagged line" "$(cat "$test_tmp/events-output")"
|
||||
grep -q "Added Events (events@example.com)." "$notifications" || fail "--events also notifies, in case the panel closed"
|
||||
if OMARCHY_TEST_LOGIN_UUID=u-events OMARCHY_TEST_LOGIN_EMAIL=events@example.com \
|
||||
omarchy-agent-account-add --events claude Again </dev/null >"$test_tmp/events-dup" 2>&1; then
|
||||
fail "--events fails a duplicate"
|
||||
fi
|
||||
grep -q "^@@omarchy error That's Events" "$test_tmp/events-dup" || fail "--events reports a failure as a tagged line" "$(cat "$test_tmp/events-dup")"
|
||||
pass "--events reports progress and results for the panel"
|
||||
|
||||
# ------------------------------------------------------------------- reauth
|
||||
|
||||
: >"$OMARCHY_TEST_BROWSER_LOG"
|
||||
before=$(omarchy-agent-account-list claude --json | jq '.[0].accounts | length')
|
||||
rm -f "$accounts/claude/events/.credentials.json"
|
||||
OMARCHY_TEST_LOGIN_UUID=u-events OMARCHY_TEST_LOGIN_EMAIL=events@example.com \
|
||||
omarchy-agent-account-add --events --reauth events claude </dev/null >"$test_tmp/reauth-output"
|
||||
grep -qx "@@omarchy done Signed in to Claude again." "$test_tmp/reauth-output" || fail "--reauth reports the sign-in" "$(cat "$test_tmp/reauth-output")"
|
||||
[[ -s $accounts/claude/events/.credentials.json ]] || fail "--reauth signs in to the account's own home"
|
||||
[[ $(omarchy-agent-account-list claude --json | jq '.[0].accounts | length') == "$before" ]] || fail "--reauth adds no account"
|
||||
grep -qx -- "--private https://claude.com/oauth/authorize" "$OMARCHY_TEST_BROWSER_LOG" || fail "--reauth of an added account uses a private window"
|
||||
|
||||
: >"$OMARCHY_TEST_BROWSER_LOG"
|
||||
OMARCHY_TEST_LOGIN_UUID=u-main OMARCHY_TEST_LOGIN_EMAIL=me@example.com \
|
||||
omarchy-agent-account-add --reauth main claude </dev/null >/dev/null
|
||||
grep -qx "default https://claude.com/oauth/authorize" "$OMARCHY_TEST_BROWSER_LOG" || fail "--reauth of the primary uses the normal browser"
|
||||
|
||||
if omarchy-agent-account-add --reauth nobody claude </dev/null >/dev/null 2>&1; then
|
||||
fail "--reauth of an unknown account fails"
|
||||
fi
|
||||
pass "--reauth signs an existing account in again where it lives"
|
||||
|
||||
# ------------------------------------------------------------------- cancel
|
||||
|
||||
# Cancelling from the panel stops a login that's waiting on the browser, right
|
||||
# away, and leaves no half-made account behind.
|
||||
OMARCHY_TEST_LOGIN_HANGS="$test_tmp/login.pid" omarchy-agent-account-add --events claude Slow </dev/null >/dev/null 2>&1 &
|
||||
adding=$!
|
||||
sleep 1
|
||||
started=$(date +%s)
|
||||
kill -TERM "$adding"
|
||||
wait "$adding" || true
|
||||
(( $(date +%s) - started < 3 )) || fail "cancelling stops a waiting login at once"
|
||||
login_pid=$(cat "$test_tmp/login.pid")
|
||||
for _ in {1..20}; do
|
||||
kill -0 "$login_pid" 2>/dev/null || break
|
||||
sleep 0.1
|
||||
done
|
||||
if kill -0 "$login_pid" 2>/dev/null; then
|
||||
kill "$login_pid"
|
||||
fail "cancelling stops the login itself"
|
||||
fi
|
||||
[[ -z $(ls -A "$accounts/claude/.pending") ]] || fail "cancelling leaves no half-made account"
|
||||
pass "cancelling a sign-in stops the login and cleans up"
|
||||
|
||||
# ------------------------------------------------------------------ one account
|
||||
|
||||
# With one account, the usage record keeps its limits at the top level, and
|
||||
# the list still shows them.
|
||||
solo="$test_tmp/solo"
|
||||
mkdir -p "$solo/omarchy/agents/usage"
|
||||
echo '{"id":"claude","limits":[{"label":"Session (5-hour)","percent":0.4,"resetsAt":""}]}' >"$solo/omarchy/agents/usage/claude.json"
|
||||
[[ $(XDG_STATE_HOME="$solo" omarchy-agent-account-list claude --json | jq -c '.[0].accounts[0].limits[0].percent') == "0.4" ]] ||
|
||||
fail "a single account's limits are listed from the record's top level"
|
||||
pass "a single account's limits are listed"
|
||||
|
||||
# ------------------------------------------------------------- signed out
|
||||
|
||||
# A login that's gone is gone, whatever the registry remembered about it.
|
||||
rm -f "$HOME/.codex/auth.json"
|
||||
[[ $(omarchy-agent-account-add --check | grep '^codex ') == "codex first" ]] ||
|
||||
fail "a signed-out primary counts as a first sign-in again" "$(omarchy-agent-account-add --check)"
|
||||
pass "a signed-out account no longer counts as signed in"
|
||||
|
||||
# --------------------------------------------------------------- refresh
|
||||
|
||||
# A home signed in to someone else since it was added is saved as who it is
|
||||
# now, which is what the usage records name it by.
|
||||
jq '.oauthAccount.emailAddress = "switched@example.com"' "$accounts/claude/events/.claude.json" >"$test_tmp/switched.json"
|
||||
mv "$test_tmp/switched.json" "$accounts/claude/events/.claude.json"
|
||||
omarchy-agent-account-state refresh claude
|
||||
[[ $(jq -r '.accounts[] | select(.id == "events") | .email' "$accounts/claude.json") == "switched@example.com" ]] ||
|
||||
fail "refresh saves who each home is signed in as now"
|
||||
pass "refresh saves who each home is signed in as now"
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
require_command jq
|
||||
require_command python3
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
export HOME="$test_tmp/home"
|
||||
export XDG_STATE_HOME="$test_tmp/state"
|
||||
export XDG_CACHE_HOME="$test_tmp/cache"
|
||||
export XDG_DATA_HOME="$test_tmp/data"
|
||||
unset CLAUDE_CONFIG_DIR CODEX_HOME
|
||||
|
||||
accounts="$XDG_STATE_HOME/omarchy/agents/accounts"
|
||||
mkdir -p "$HOME/.claude/projects" "$HOME/.codex/sessions" "$accounts/claude/work" "$accounts/claude/old" "$accounts/codex/side"
|
||||
|
||||
future_ms=$(( ($(date +%s) + 3600) * 1000 ))
|
||||
open_at=$(python3 -c 'import datetime as dt; print((dt.datetime.now(dt.timezone.utc) + dt.timedelta(hours=3)).isoformat())')
|
||||
|
||||
credentials() {
|
||||
printf '{"claudeAiOauth":{"accessToken":"%s","expiresAt":%s,"rateLimitTier":"%s","subscriptionType":"max"}}\n' "$1" "$2" "$3"
|
||||
}
|
||||
credentials token-main "$future_ms" default_claude_max_20x >"$HOME/.claude/.credentials.json"
|
||||
credentials token-work "$future_ms" default_claude_max_5x >"$accounts/claude/work/.credentials.json"
|
||||
credentials token-old 1000 default_claude_max_5x >"$accounts/claude/old/.credentials.json"
|
||||
|
||||
# A parked account whose sign-in lapsed still has its last numbers on disk.
|
||||
mkdir -p "$XDG_CACHE_HOME/omarchy/agent-usage"
|
||||
jq -nc --arg open "$open_at" '{fetchedAtMs: 1, limits: [{label: "Weekly (7-day)", percent: 0.4, resetsAt: $open}]}' \
|
||||
>"$XDG_CACHE_HOME/omarchy/agent-usage/claude-limits-old.json"
|
||||
|
||||
cat >"$accounts/claude.json" <<JSON
|
||||
{
|
||||
"active": "work",
|
||||
"switch": "auto",
|
||||
"threshold": 95,
|
||||
"accounts": [
|
||||
{"id": "main", "label": "Main", "home": "", "primary": true, "email": "me@example.com"},
|
||||
{"id": "work", "label": "Work", "home": "$accounts/claude/work", "primary": false, "email": "work@example.com", "accountId": "u-work"},
|
||||
{"id": "old", "label": "Old", "home": "$accounts/claude/old", "primary": false, "email": "old@example.com"}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
# Anthropic answers per token, so each account's probe is told apart by the
|
||||
# credential it carried.
|
||||
claude_record=$(COLLECTOR="$ROOT/bin/omarchy-agent-usage-claude" python3 - <<'PY'
|
||||
import importlib.machinery, importlib.util, io, json, os, sys
|
||||
|
||||
loader = importlib.machinery.SourceFileLoader("collector", os.environ["COLLECTOR"])
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
collector = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(collector)
|
||||
|
||||
usage = {"token-main": 97.0, "token-work": 12.0}
|
||||
|
||||
def urlopen(request, timeout=None):
|
||||
token = request.get_header("Authorization").split(" ", 1)[1]
|
||||
return io.BytesIO(json.dumps({"five_hour": {"utilization": usage[token]}, "seven_day": {"utilization": 5.0}}).encode())
|
||||
|
||||
collector.urllib.request.urlopen = urlopen
|
||||
collector.scan_pi_usage = lambda age: None
|
||||
collector.scan_opencode_usage = lambda age: None
|
||||
sys.argv = ["omarchy-agent-usage-claude", "--force"]
|
||||
collector.main()
|
||||
PY
|
||||
)
|
||||
|
||||
[[ $(jq -c '[.accounts[] | {id, active, plan, stale}]' <<<"$claude_record") == '[{"id":"main","active":false,"plan":"Max 20x","stale":false},{"id":"work","active":true,"plan":"Max 5x","stale":false},{"id":"old","active":false,"plan":"Max 5x","stale":true}]' ]] ||
|
||||
fail "Claude record lists every account with its own plan" "$claude_record"
|
||||
pass "Claude record lists every registered account"
|
||||
|
||||
[[ $(jq -c '[.accounts[] | .limits[0].percent]' <<<"$claude_record") == '[0.97,0.12,0.4]' ]] ||
|
||||
fail "Claude record probes each account with its own sign-in" "$claude_record"
|
||||
pass "Claude record probes each account with its own sign-in"
|
||||
|
||||
[[ $(jq -r '.accounts[2].usageStatusText' <<<"$claude_record") == "Sign-in expired" ]] ||
|
||||
fail "a lapsed account keeps its last-known limits and says why" "$claude_record"
|
||||
pass "a lapsed account keeps its last-known limits and says why"
|
||||
|
||||
[[ $(jq -c '{tierLabel, first: .limits[0].percent}' <<<"$claude_record") == '{"tierLabel":"Max 5x","first":0.12}' ]] ||
|
||||
fail "the record's own limits describe the active account" "$claude_record"
|
||||
pass "the record's own limits describe the active account"
|
||||
|
||||
[[ -f $XDG_CACHE_HOME/omarchy/agent-usage/claude-limits.json && -f $XDG_CACHE_HOME/omarchy/agent-usage/claude-limits-u-work.json ]] ||
|
||||
fail "each account keeps its own limits cache, keyed by subscription"
|
||||
pass "each account keeps its own limits cache"
|
||||
|
||||
# Once every window a lapsed account last saw has reset, it has its whole
|
||||
# allowance back: that reads as 0%, not as nothing known.
|
||||
past_at=$(python3 -c 'import datetime as dt; print((dt.datetime.now(dt.timezone.utc) - dt.timedelta(minutes=5)).isoformat())')
|
||||
jq -nc --arg past "$past_at" '{fetchedAtMs: 1, limits: [{label: "Session (5-hour)", percent: 0.99, resetsAt: $past}]}' \
|
||||
>"$XDG_CACHE_HOME/omarchy/agent-usage/claude-limits-old.json"
|
||||
rested=$(COLLECTOR="$ROOT/bin/omarchy-agent-usage-claude" python3 - <<'PY'
|
||||
import importlib.machinery, importlib.util, io, json, os, sys
|
||||
|
||||
loader = importlib.machinery.SourceFileLoader("collector", os.environ["COLLECTOR"])
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
collector = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(collector)
|
||||
collector.urllib.request.urlopen = lambda request, timeout=None: io.BytesIO(b'{"five_hour": {"utilization": 30.0}}')
|
||||
collector.scan_pi_usage = lambda age: None
|
||||
collector.scan_opencode_usage = lambda age: None
|
||||
sys.argv = ["omarchy-agent-usage-claude", "--force"]
|
||||
collector.main()
|
||||
PY
|
||||
)
|
||||
[[ $(jq -c '.accounts[2] | {stale, limits: [.limits[] | {label, empty: (.percent == 0)}]}' <<<"$rested") == '{"stale":true,"limits":[{"label":"Session (5-hour)","empty":true}]}' ]] ||
|
||||
fail "a lapsed account whose windows all reset reads as untouched" "$rested"
|
||||
pass "a lapsed account whose windows all reset reads as untouched"
|
||||
|
||||
# Signing the primary home in to another subscription must not inherit the
|
||||
# last one's numbers when the first probe for the new one fails.
|
||||
printf '{"oauthAccount":{"accountUuid":"u-new"}}\n' >"$HOME/.claude.json"
|
||||
now_ms=$(( $(date +%s) * 1000 ))
|
||||
jq -nc --arg open "$open_at" --argjson now "$now_ms" '{fetchedAtMs: $now, limits: [{label: "Session (5-hour)", percent: 0.55, resetsAt: $open}]}' \
|
||||
>"$XDG_CACHE_HOME/omarchy/agent-usage/claude-limits.json"
|
||||
resigned=$(COLLECTOR="$ROOT/bin/omarchy-agent-usage-claude" python3 - <<'PY'
|
||||
import importlib.machinery, importlib.util, io, json, os, sys
|
||||
|
||||
loader = importlib.machinery.SourceFileLoader("collector", os.environ["COLLECTOR"])
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
collector = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(collector)
|
||||
|
||||
def urlopen(request, timeout=None):
|
||||
if request.get_header("Authorization").endswith("token-main"):
|
||||
raise collector.urllib.error.URLError("down")
|
||||
return io.BytesIO(b'{"five_hour": {"utilization": 12.0}}')
|
||||
|
||||
collector.urllib.request.urlopen = urlopen
|
||||
collector.scan_pi_usage = lambda age: None
|
||||
collector.scan_opencode_usage = lambda age: None
|
||||
sys.argv = ["omarchy-agent-usage-claude", "--force"]
|
||||
collector.main()
|
||||
PY
|
||||
)
|
||||
[[ $(jq -c '.accounts[0].limits | map(.percent)' <<<"$resigned") != *0.55* ]] ||
|
||||
fail "a primary home signed in to another subscription doesn't inherit its limits" "$resigned"
|
||||
rm -f "$HOME/.claude.json"
|
||||
pass "a primary home signed in to another subscription doesn't inherit its limits"
|
||||
|
||||
# A registry with one account changes nothing about the record.
|
||||
jq '.accounts |= [.[0]] | .active = "main"' "$accounts/claude.json" >"$test_tmp/one.json"
|
||||
mv "$test_tmp/one.json" "$accounts/claude.json"
|
||||
single=$(COLLECTOR="$ROOT/bin/omarchy-agent-usage-claude" python3 - <<'PY'
|
||||
import importlib.machinery, importlib.util, io, json, os, sys
|
||||
|
||||
loader = importlib.machinery.SourceFileLoader("collector", os.environ["COLLECTOR"])
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
collector = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(collector)
|
||||
collector.urllib.request.urlopen = lambda request, timeout=None: io.BytesIO(b'{"five_hour": {"utilization": 30.0}}')
|
||||
collector.scan_pi_usage = lambda age: None
|
||||
collector.scan_opencode_usage = lambda age: None
|
||||
sys.argv = ["omarchy-agent-usage-claude", "--force"]
|
||||
collector.main()
|
||||
PY
|
||||
)
|
||||
[[ $(jq 'has("accounts")' <<<"$single") == false && $(jq '.limits[0].percent' <<<"$single") == 0.3 ]] ||
|
||||
fail "a single account record carries no accounts list" "$single"
|
||||
pass "a single account record is unchanged"
|
||||
|
||||
[[ $(jq -c '{limitsStale, fresh: (.limitsFetchedAt > 0)}' <<<"$single") == '{"limitsStale":false,"fresh":true}' ]] ||
|
||||
fail "limits checked just now aren't stale" "$single"
|
||||
# A failed probe falls back on hour-old numbers, and the record says so.
|
||||
hour_ago_ms=$(( ($(date +%s) - 3600) * 1000 ))
|
||||
jq -nc --arg open "$open_at" --argjson at "$hour_ago_ms" '{fetchedAtMs: $at, limits: [{label: "Session (5-hour)", percent: 0.42, resetsAt: $open}]}' \
|
||||
>"$XDG_CACHE_HOME/omarchy/agent-usage/claude-limits.json"
|
||||
kept=$(COLLECTOR="$ROOT/bin/omarchy-agent-usage-claude" python3 - <<'PY'
|
||||
import importlib.machinery, importlib.util, os, sys
|
||||
|
||||
loader = importlib.machinery.SourceFileLoader("collector", os.environ["COLLECTOR"])
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
collector = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(collector)
|
||||
|
||||
def urlopen(request, timeout=None):
|
||||
raise collector.urllib.error.URLError("down")
|
||||
|
||||
collector.urllib.request.urlopen = urlopen
|
||||
collector.scan_pi_usage = lambda age: None
|
||||
collector.scan_opencode_usage = lambda age: None
|
||||
sys.argv = ["omarchy-agent-usage-claude", "--force"]
|
||||
collector.main()
|
||||
PY
|
||||
)
|
||||
[[ $(jq -c --argjson at "$hour_ago_ms" '{limitsStale, at: (.limitsFetchedAt == $at), first: .limits[0].percent}' <<<"$kept") == '{"limitsStale":true,"at":true,"first":0.42}' ]] ||
|
||||
fail "kept limits are marked stale with when they were measured" "$kept"
|
||||
pass "the record says when its limits are kept from an earlier check"
|
||||
|
||||
# ------------------------------------------------------------------------ codex
|
||||
|
||||
# A stand-in app-server that answers for whichever home it was started in.
|
||||
mkdir -p "$test_tmp/bin"
|
||||
cat >"$test_tmp/bin/codex" <<'PY'
|
||||
#!/usr/bin/python3
|
||||
import json, os, sys
|
||||
used = 91 if os.environ.get("CODEX_HOME", "").endswith("/side") else 40
|
||||
for line in sys.stdin:
|
||||
message = json.loads(line)
|
||||
if "id" not in message:
|
||||
continue
|
||||
result = {}
|
||||
if message["method"] == "account/rateLimits/read":
|
||||
result = {"rateLimits": {"planType": "pro", "primary": {"usedPercent": used, "windowDurationMins": 300}}}
|
||||
print(json.dumps({"id": message["id"], "result": result}), flush=True)
|
||||
PY
|
||||
chmod +x "$test_tmp/bin/codex"
|
||||
|
||||
cat >"$accounts/codex.json" <<JSON
|
||||
{
|
||||
"active": "main",
|
||||
"accounts": [
|
||||
{"id": "main", "label": "Main", "home": "", "primary": true},
|
||||
{"id": "side", "label": "Side", "home": "$accounts/codex/side", "primary": false}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
codex_record=$(PATH="$test_tmp/bin:$PATH" "$ROOT/bin/omarchy-agent-usage-codex" --force)
|
||||
[[ $(jq -c '[.accounts[] | {id, active, used: .limits[0].percent}]' <<<"$codex_record") == '[{"id":"main","active":true,"used":0.4},{"id":"side","active":false,"used":0.91}]' ]] ||
|
||||
fail "Codex record asks each account's own app-server" "$codex_record"
|
||||
[[ $(jq '.limits[0].percent' <<<"$codex_record") == 0.4 ]] || fail "Codex record's own limits describe the active account" "$codex_record"
|
||||
pass "Codex record lists every registered account"
|
||||
|
||||
inherited=$(CODEX_HOME="$accounts/codex/side" PATH="$test_tmp/bin:$PATH" "$ROOT/bin/omarchy-agent-usage-codex" --force)
|
||||
[[ $(jq -c '[.accounts[] | .limits[0].percent]' <<<"$inherited") == '[0.4,0.91]' ]] ||
|
||||
fail "Main's Codex limits come from ~/.codex whatever CODEX_HOME says" "$inherited"
|
||||
pass "Main's Codex limits come from ~/.codex whatever CODEX_HOME says"
|
||||
@@ -240,4 +240,53 @@ assertDeepEqual(
|
||||
[{ title: 'Weekly', percent: 0.12, resetAt: '' }],
|
||||
'agents panel still reads a window out of a label that carries no title'
|
||||
)
|
||||
|
||||
// A model's weekly allowance rides under the Weekly row; one whose window has
|
||||
// no row of its own keeps a row.
|
||||
assertDeepEqual(
|
||||
displayWindows({ limits: [
|
||||
{ label: 'Session (5-hour)', percent: 0.94, resetsAt: '' },
|
||||
{ label: 'Weekly (7-day)', percent: 0.25, resetsAt: 'w' },
|
||||
{ label: 'Fable Weekly', title: 'Fable Weekly', percent: 0.09, resetsAt: 'w' },
|
||||
{ label: 'Opus Monthly', title: 'Opus Monthly', percent: 0.5, resetsAt: '' }
|
||||
] }),
|
||||
[
|
||||
{ title: 'Session', percent: 0.94, resetAt: '', scoped: [] },
|
||||
{ title: 'Weekly', percent: 0.25, resetAt: 'w', scoped: [{ title: 'Fable', percent: 0.09, resetAt: 'w' }] },
|
||||
{ title: 'Opus Monthly', percent: 0.5, resetAt: '', scoped: [] }
|
||||
],
|
||||
'agents panel shows a model-scoped window under the window it runs on'
|
||||
)
|
||||
JS
|
||||
|
||||
# Anthropic rate-limits its usage endpoint readily. A refused re-check of
|
||||
# numbers measured minutes ago still describes the account; only older numbers
|
||||
# count as stale, and either way the record says when they were measured.
|
||||
rate_limited() {
|
||||
COLLECTOR="$ROOT/bin/omarchy-agent-usage-claude" FETCHED_AGO="$1" XDG_CACHE_HOME="$CACHE_HOME" python3 - <<'PY'
|
||||
import datetime as dt, importlib.machinery, importlib.util, json, os, time, urllib.error
|
||||
|
||||
loader = importlib.machinery.SourceFileLoader("collector", os.environ["COLLECTOR"])
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
collector = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(collector)
|
||||
|
||||
reset = (dt.datetime.now(dt.timezone.utc) + dt.timedelta(hours=3)).isoformat()
|
||||
fetched = round((time.time() - float(os.environ["FETCHED_AGO"])) * 1000)
|
||||
(collector.cache_root() / "claude-limits-rate.json").write_text(json.dumps(
|
||||
{"fetchedAtMs": fetched, "limits": [{"label": "Session (5-hour)", "percent": 0.4, "resetsAt": reset}]}))
|
||||
|
||||
def refused(request, timeout=None):
|
||||
raise urllib.error.HTTPError(request.full_url, 429, "Too Many Requests", {}, None)
|
||||
|
||||
collector.urllib.request.urlopen = refused
|
||||
result = collector.collect_limits("token", int((time.time() + 3600) * 1000), True, "claude-limits-rate.json")
|
||||
print(json.dumps({"live": result["live"], "percent": result["limits"][0]["percent"], "fetched": result["fetchedAtMs"] == fetched}))
|
||||
PY
|
||||
}
|
||||
|
||||
[[ $(rate_limited 60) == '{"live": true, "percent": 0.4, "fetched": true}' ]] ||
|
||||
fail "a refused re-check of numbers a minute old still counts as current" "$(rate_limited 60)"
|
||||
[[ $(rate_limited 1800) == '{"live": false, "percent": 0.4, "fetched": true}' ]] ||
|
||||
fail "a refused re-check of half-hour-old numbers counts as stale" "$(rate_limited 1800)"
|
||||
pass "a rate-limited check only goes stale once the numbers are old"
|
||||
@@ -31,7 +31,12 @@ while read -r request; do
|
||||
jq -cn --argjson id "$id" '{id: $id, result: {account: {}}}'
|
||||
;;
|
||||
account/rateLimits/read)
|
||||
jq -cn --argjson id "$id" --argjson limits "${CODEX_RATE_LIMITS:-{\}}" '{id: $id, result: {rateLimits: $limits}}'
|
||||
if [[ -n ${CODEX_LIMITS_ERROR:-} ]]; then
|
||||
jq -cn --argjson id "$id" --arg message "$CODEX_LIMITS_ERROR" '{id: $id, error: {code: -32600, message: $message}}'
|
||||
continue
|
||||
fi
|
||||
jq -cn --argjson id "$id" --argjson limits "${CODEX_RATE_LIMITS:-{\}}" --argjson credits "${CODEX_RESET_CREDITS:-null}" \
|
||||
'{id: $id, result: {rateLimits: $limits, rateLimitResetCredits: $credits}}'
|
||||
;;
|
||||
esac
|
||||
done
|
||||
@@ -616,3 +621,22 @@ result=$(HOME="$TEST_HOME" CODEX_HOME="$TEST_HOME/.codex" XDG_DATA_HOME="$TEST_H
|
||||
[[ $(jq -c '{tierLabel, usageStatusText, limits: [.limits[] | {label, percent}]}' <<<"$result") == '{"tierLabel":"pro","usageStatusText":"","limits":[{"label":"Weekly (7-day)","percent":0.36}]}' ]] ||
|
||||
fail "Codex collector reads limits even when account/read never answers" "$result"
|
||||
pass "Codex collector reads limits even when account/read never answers"
|
||||
|
||||
# Free full resets ride along with the limits: only available ones count, and
|
||||
# the soonest to lapse is the one worth mentioning.
|
||||
result=$(HOME="$TEST_HOME" CODEX_HOME="$TEST_HOME/.codex" XDG_DATA_HOME="$TEST_HOME/.local/share" PATH="$TEST_HOME/bin:$PATH" \
|
||||
CODEX_RATE_LIMITS='{"planType":"pro","primary":{"usedPercent":42,"windowDurationMins":10080}}' \
|
||||
CODEX_RESET_CREDITS='{"availableCount":2,"credits":[{"status":"available","expiresAt":2000000000},{"status":"available","expiresAt":1900000000},{"status":"used","expiresAt":1800000000}]}' \
|
||||
"$ROOT/bin/omarchy-agent-usage-codex" --limits-only)
|
||||
[[ $(jq -c '.resetCredits' <<<"$result") == '{"available":2,"nextExpiresAt":"2030-03-17T17:46:40+00:00"}' ]] ||
|
||||
fail "Codex collector reports its available free resets" "$result"
|
||||
pass "Codex collector reports its available free resets"
|
||||
|
||||
# A home nobody is signed in to answers with an error, which reads as a
|
||||
# sign-in to restore rather than as missing numbers.
|
||||
result=$(HOME="$TEST_HOME" CODEX_HOME="$TEST_HOME/.codex" XDG_DATA_HOME="$TEST_HOME/.local/share" PATH="$TEST_HOME/bin:$PATH" \
|
||||
CODEX_LIMITS_ERROR="codex account authentication required to read rate limits" \
|
||||
"$ROOT/bin/omarchy-agent-usage-codex" --limits-only)
|
||||
[[ $(jq -r '.usageStatusText' <<<"$result") == "Waiting for auth" ]] ||
|
||||
fail "Codex collector reports a missing sign-in as one" "$result"
|
||||
pass "Codex collector reports a missing sign-in as one"
|
||||
@@ -11,7 +11,26 @@ const panelSource = fs.readFileSync(root + '/shell/plugins/agents/Panel.qml', 'u
|
||||
assert(/function launchAgent\(\)/.test(panelSource), 'agents panel launches the default agent')
|
||||
assert(/root\.bar\.run\("omarchy-agent --pick"\)/.test(panelSource), 'agents panel uses the desktop agent launcher')
|
||||
assert(/if \(buttonCode === Qt\.RightButton\) root\.launchAgent\(\)/.test(panelSource), 'agents right click launches the agent')
|
||||
assert(/else if \(buttonCode === Qt\.MiddleButton\) root\.selectProvider\(root\.providerIndex \+ 1\)/.test(panelSource), 'agents middle click still advances the subscription')
|
||||
assert(/else if \(buttonCode === Qt\.MiddleButton\) root\.refreshNow\(\)/.test(panelSource), 'agents middle click refreshes the limits')
|
||||
assert(/else root\.toggle\(\)/.test(panelSource), 'agents left click still toggles the panel')
|
||||
assert(!/if \(buttonCode === Qt\.RightButton\) root\.refreshNow\(\)/.test(panelSource), 'agents right click no longer refreshes')
|
||||
assert(/if \(root\.addStage === "" \|\| root\.picking\) root\.moveKey\(dx, dy\)/.test(panelSource), 'arrows move the agents panel cursor on the page and while picking an agent to add')
|
||||
assert(/target\.kind === "choice"\) chooseAddProvider\(/.test(panelSource), 'Enter on an agent to add chooses it')
|
||||
assert(!/text: root\.addStage === "running" \? "Cancel" : "Back"/.test(panelSource), 'the hero X is the only way back from adding')
|
||||
assert(/hasCursor: root\.hasKey\("add"\)/.test(panelSource) && /hasCursor: root\.hasKey\("launch"\)/.test(panelSource), 'the hero buttons take the keyboard cursor')
|
||||
assert(/hasCursor: root\.hasKey\("starter", index\)/.test(panelSource), 'the starter tiles take the keyboard cursor')
|
||||
assert(/root\.pointAt\("account", Number\(t\) - 1\)/.test(panelSource), 'number keys move the cursor to an account')
|
||||
assert(/target\.kind === "launch"\) launchAgent\(\)/.test(panelSource), 'Enter on the launcher starts the default agent')
|
||||
assert(/row\.push\(\{ kind: "autoswitch", index: entry \}\)/.test(panelSource), 'an inactive account offers Autoswitch and Use as separate stops')
|
||||
assert(/kind: "signin", index: entry/.test(panelSource) && /kind: "providerSignin", index: p/.test(panelSource), 'Sign-in required links are keyboard stops')
|
||||
assert(/target\.kind === "signin"\) signInAgain\(/.test(panelSource) && /target\.kind === "providerSignin"\) signInAgain\(/.test(panelSource), 'Enter on Sign-in required signs in again')
|
||||
assert(/target\.kind === "autoswitch"\) setSwitchMode\(/.test(panelSource), 'Enter on Autoswitch flips the switch mode')
|
||||
assert(/keyColumn = use >= 0 \? use : /.test(panelSource), 'moving up or down onto an account lands on Use')
|
||||
assert(/Qt\.callLater\(function\(\) \{ if \(picking\) pointAt\("choice", 0\) \}\)/.test(panelSource), 'picking an agent to add starts with the first one focused')
|
||||
assert(/opacity: stale \? 0\.5 : 1\.0/.test(panelSource) && /"As of " \+ root\.formatDuration/.test(panelSource), 'limits kept from an earlier check dim and say how old they are on hover')
|
||||
assert(/onPickingChanged: resetKeys\(\)/.test(panelSource), 'the cursor starts over when the agent list comes or goes')
|
||||
assert(!/t === "a" \|\| t === "A"/.test(panelSource), 'adding an account has no hotkey; the + is the way in')
|
||||
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')
|
||||
JS
|
||||
@@ -275,7 +275,8 @@ timeout_marker=$(find "$timeout_cache/omarchy/image-selector" -maxdepth 1 -type
|
||||
|
||||
grep -qx 'owe' "$ROOT/install/omarchy-base.packages" || fail "OWE is a base package"
|
||||
grep -qx 'owe-lockfeed' "$ROOT/install/omarchy-base.packages" || fail "the OWE lock feed module is a base package"
|
||||
if grep -qx 'qt6-multimedia' "$ROOT/install/omarchy-base.packages"; then
|
||||
# Qt Multimedia ships for building apps; the shell itself plays video through OWE.
|
||||
if grep -rqs 'import QtMultimedia' "$ROOT/shell"; then
|
||||
fail "Qt Multimedia is no longer needed by the shell"
|
||||
fi
|
||||
|
||||
|
||||
Reference in new issue
Block a user