From f4ce5a6cdb9a0913ed876fdf1ab7edce94eaf53d Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 24 Oct 2026 16:07:54 -0400 Subject: [PATCH 01/11] Offer every subscription and API-key provider on the panel's add screen The add screen lists the subscriptions the add command signs in to, then the providers that take an API key, in a four-across grid the keyboard walks row by row. A key provider asks for its key, sent to the add command on stdin, and links to where the provider hands one out. Device codes are found anywhere in a line, as gh and Kimi print them in a sentence. Co-Authored-By: Claude Opus 5.5 --- default/i18n/en/shell.json | 7 + shell/plugins/agents/Panel.qml | 244 ++++++++++++++++++++++++++------- 2 files changed, 198 insertions(+), 53 deletions(-) diff --git a/default/i18n/en/shell.json b/default/i18n/en/shell.json index 7f8d52e52af..2a148de921b 100644 --- a/default/i18n/en/shell.json +++ b/default/i18n/en/shell.json @@ -3,6 +3,7 @@ "agents.addAccount": "Add an account", "agents.addProviderAccount": "Add %1 account", "agents.addSubscription": "Add a subscription", + "agents.apiKeys": "API keys", "agents.app": "App", "agents.autoswitch": "Autoswitch", "agents.backToLimits": "Back to the limits", @@ -25,6 +26,7 @@ "one": "%1 free reset · next expires in %2", "other": "%1 free resets · next expires in %2" }, + "agents.getKey": "Get a key", "agents.helpFireworksSignIn": "Set %1, run %2, or sign in to Fireworks in pi or opencode.", "agents.helpNotInPath": "%1 not found in PATH", "agents.helpRunToAuthenticate": "Run %1 to authenticate.", @@ -33,6 +35,7 @@ "agents.helpSignInExpired": "%1's saved sign-in expired. Start %1 to refresh it.", "agents.helpStartOrRunToSignIn": "Start %1, or run %2, to sign in.", "agents.helpStartToSeeLimits": "Start %1 to see its limits; signing in again is not needed.", + "agents.keySaved": "Key saved", "agents.lastUpdatedAgo": "Last updated %1 ago", "agents.lastUpdatedJustNow": "Last updated less than a minute ago", "agents.lastUpdatedUnknown": "Last updated time unavailable", @@ -49,7 +52,9 @@ "agents.nameAccount": "Name this account. It signs in through a private window, so your browser's current account isn't picked up.", "agents.outOfCredits": "Out of credits", "agents.pasteCode": "If the page shows a code instead of finishing, paste it here.", + "agents.pasteKey": "Paste your %1 API key. It stays on this machine, readable only by you, and is checked with %1 once.", "agents.placeholderCode": "Code", + "agents.placeholderKey": "API key", "agents.placeholderWork": "Work", "agents.plugin": "Plugin", "agents.promptsToday": { @@ -59,6 +64,7 @@ "agents.providerLimitsUnavailable": "%1 limits unavailable", "agents.providerUnavailable": "%1 unavailable", "agents.reopenSignIn": "Open the sign-in page again", + "agents.savingKey": "Checking the key…", "agents.scopedMonthly": "%1: %2% of its monthly allowance", "agents.scopedOther": "%1: %2% of its %3 allowance", "agents.scopedSession": "%1: %2% of its session allowance", @@ -73,6 +79,7 @@ "agents.signInIntro": "Sign in to an AI coding subscription, and this panel keeps track of how much of it you have left.", "agents.signInRequired": "Sign-in required", "agents.signInUnfinished": "The sign-in didn't finish.", + "agents.signedInShort": "Signed in", "agents.startDefaultAgent": "Start the default agent", "agents.startOnNew": "Start your default agent on a new %1", "agents.starting": "Starting…", diff --git a/shell/plugins/agents/Panel.qml b/shell/plugins/agents/Panel.qml index 111737b20cc..77dea68a450 100644 --- a/shell/plugins/agents/Panel.qml +++ b/shell/plugins/agents/Panel.qml @@ -64,9 +64,14 @@ Panel { if (addStage === "") hero.push({ kind: "launch", index: 0 }) if (hero.length > 0) rows.push(hero) if (picking) { - var choices = [] - for (var k = 0; k < addProviders.length; k++) choices.push({ kind: "choice", index: k }) - rows.push(choices) + for (var g = 0; g < addGroups.length; g++) { + var members = addGroups[g].members + for (var k = 0; k < members.length; k += addColumns) { + var choices = [] + for (var m = k; m < Math.min(k + addColumns, members.length); m++) choices.push({ kind: "choice", index: members[m] }) + rows.push(choices) + } + } } else if (addStage === "") { // Accounts count in the same order as accountEntries. Only what can be // done is a stop: one that isn't active offers Autoswitch beside Use, a @@ -240,11 +245,38 @@ Panel { // close the panel; the login carries on, and its result arrives as a // notification too. + // Subscriptions sign in through their own CLI; the rest take an API key + // pasted here, saved where their collector reads it. keyUrl is where the + // provider hands one out. readonly property var addProviders: [ - { providerId: "claude", providerName: "Claude Code" }, - { providerId: "codex", providerName: "Codex" }, - { providerId: "grok", providerName: "Grok" } + { providerId: "claude", providerName: "Claude Code", kind: "signin" }, + { providerId: "codex", providerName: "Codex", kind: "signin" }, + { providerId: "grok", providerName: "Grok", kind: "signin" }, + { providerId: "cursor", providerName: "Cursor", kind: "signin" }, + { providerId: "copilot", providerName: "Copilot", kind: "signin" }, + { providerId: "kimi", providerName: "Kimi", kind: "signin" }, + { providerId: "muse", providerName: "Muse Code", kind: "signin" }, + { providerId: "agy", providerName: "Antigravity", kind: "signin" }, + { providerId: "devin", providerName: "Devin", kind: "signin" }, + { providerId: "zai", providerName: "Z.ai", kind: "key", keyUrl: "https://z.ai/manage-apikey/apikey-list" }, + { providerId: "minimax", providerName: "MiniMax", kind: "key", keyUrl: "https://platform.minimax.io/user-center/basic-information/interface-key" }, + { providerId: "synthetic", providerName: "Synthetic", kind: "key", keyUrl: "https://synthetic.new" }, + { providerId: "ollama", providerName: "Ollama Cloud", kind: "key", keyUrl: "https://ollama.com/settings/keys" }, + { providerId: "openrouter", providerName: "OpenRouter", kind: "key", keyUrl: "https://openrouter.ai/settings/keys" }, + { providerId: "deepseek", providerName: "DeepSeek", kind: "key", keyUrl: "https://platform.deepseek.com/api_keys" }, + { providerId: "fireworks", providerName: "Fireworks", kind: "key", keyUrl: "https://app.fireworks.ai/settings/users/api-keys" }, + { providerId: "opencode", providerName: "OpenCode", kind: "key", keyUrl: "https://opencode.ai/auth" }, + { providerId: "kimchi", providerName: "Kimchi", kind: "key", keyUrl: "https://kimchi.dev" } ] + // The picker's two groups, as indexes into addProviders, and how many + // tiles sit in each grid row (the keyboard walks the same rows). + readonly property int addColumns: 4 + readonly property var addGroups: { + var signin = [], key = [] + for (var i = 0; i < addProviders.length; i++) + (addProviders[i].kind === "key" ? key : signin).push(i) + return [{ kind: "signin", members: signin }, { kind: "key", members: key }] + } property string addStage: "" property string addProvider: "" property var addChecks: ({}) @@ -275,9 +307,37 @@ Panel { if (state === "") return addProvider = id if (state === "additional") addStage = "name" + else if (state === "key" || state === "keyed") addStage = "key" else startAdd("") } + function addProviderEntry(id) { + for (var i = 0; i < addProviders.length; i++) + if (addProviders[i].providerId === id) return addProviders[i] + return null + } + + // A pasted key goes to the add command on stdin, never on its command line. + property string pendingKey: "" + function startKey(key) { + if (key.trim() === "") return + pendingKey = key.trim() + addPrivate = false + addStatus = Commons.I18n.t("agents.savingKey") + addCode = "" + addUrl = "" + addNeedsPaste = false + addResult = "" + addStage = "running" + addProcess.command = ["omarchy-agent-account-add", "--events", "--key", addProvider] + addProcess.running = true + } + + function openKeyPage() { + var entry = addProviderEntry(addProvider) + if (entry && entry.keyUrl) Util.execArgv(["omarchy-launch-browser", entry.keyUrl]) + } + function startAdd(label) { addPrivate = (addChecks[addProvider] || "") === "additional" addStatus = Commons.I18n.t("agents.starting") @@ -294,6 +354,7 @@ Panel { } function cancelAdd() { + pendingKey = "" if (addProcess.running) addProcess.signal(15) addStage = "" root.focusKeys() @@ -333,7 +394,7 @@ Panel { } return } - var code = text.match(/^\s*([A-Z0-9]{4}-[A-Z0-9]{4})\s*$/) + var code = text.replace(/https?:\/\/\S+/g, "").match(/(?:^|[^A-Za-z0-9-])([A-Z0-9]{4}-[A-Z0-9]{4})(?![A-Za-z0-9-])/) if (code) addCode = code[1] if (/paste code/i.test(text)) addNeedsPaste = true var url = text.match(/(https:\/\/\S+)/) @@ -902,6 +963,7 @@ Panel { running: false stdinEnabled: true stdout: SplitParser { onRead: function(line) { root.handleAddLine(line) } } + onStarted: if (root.pendingKey !== "") { write(root.pendingKey + "\n"); root.pendingKey = "" } onExited: { if (root.addStage === "running") { root.addResult = Commons.I18n.t("agents.signInUnfinished") @@ -1180,60 +1242,136 @@ Panel { wrapMode: Text.WordWrap } - // Pick: each agent by a large mark over its name, three across. - Row { - id: choiceRow - visible: root.picking - width: parent.width - spacing: Style.space(10) + // Pick: subscriptions to sign in to, then providers that take an API + // key, each a mark over its name, four across. + Repeater { + model: root.picking ? root.addGroups : [] - Repeater { - model: root.picking ? root.addProviders : [] + Column { + id: group + required property var modelData + width: add.width + spacing: Style.space(10) - Item { - id: choice - required property var modelData - required property int index - readonly property string state: root.addChecks[modelData.providerId] || "" - readonly property bool available: state === "first" || state === "additional" - readonly property bool hasCursor: root.hasKey("choice", index) - width: (choiceRow.width - choiceRow.spacing * (root.addProviders.length - 1)) / root.addProviders.length - implicitHeight: choiceBody.implicitHeight + Style.space(16) - onHasCursorChanged: if (hasCursor) root.revealItem(choice) + Text { + text: group.modelData.kind === "key" ? Commons.I18n.t("agents.apiKeys") : Commons.I18n.t("agents.subscriptions") + textFormat: Text.PlainText + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } - Column { - id: choiceBody - anchors.centerIn: parent - spacing: Style.space(10) - - ProviderIcon { - anchors.horizontalCenter: parent.horizontalCenter - provider: choice.modelData - size: Style.font.display * 1.6 - scale: choice.hasCursor ? 1.08 : 1.0 - Behavior on scale { NumberAnimation { duration: Style.duration(120); easing.type: Easing.OutQuad } } - } + Grid { + id: choiceGrid + width: parent.width + columns: root.addColumns + columnSpacing: Style.space(10) + rowSpacing: Style.space(10) - Text { - anchors.horizontalCenter: parent.horizontalCenter - textFormat: Text.PlainText - text: choice.modelData.providerName - color: choice.hasCursor ? Commons.Color.accent : root.foreground - font.family: root.fontFamily - font.pixelSize: Style.font.body - font.bold: true + Repeater { + model: group.modelData.members + + Item { + id: choice + required property var modelData + readonly property int index: modelData + readonly property var provider: root.addProviders[index] + readonly property string state: root.addChecks[provider.providerId] || "" + readonly property bool available: state !== "" + // Signed in or holding a saved key already: picking it again + // signs in again or replaces the key. + readonly property bool done: state === "signed" || state === "keyed" + readonly property bool hasCursor: root.hasKey("choice", index) + width: (choiceGrid.width - choiceGrid.columnSpacing * (root.addColumns - 1)) / root.addColumns + implicitHeight: choiceBody.implicitHeight + Style.space(12) + opacity: available ? 1.0 : 0.5 + onHasCursorChanged: if (hasCursor) root.revealItem(choice) + + Column { + id: choiceBody + anchors.centerIn: parent + width: parent.width + spacing: Style.space(6) + + ProviderIcon { + anchors.horizontalCenter: parent.horizontalCenter + provider: choice.provider + size: Style.font.display + scale: choice.hasCursor ? 1.08 : 1.0 + Behavior on scale { NumberAnimation { duration: Style.duration(120); easing.type: Easing.OutQuad } } + } + + Text { + width: parent.width + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + textFormat: Text.PlainText + text: choice.provider.providerName + color: choice.hasCursor ? Commons.Color.accent : root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + } + + Text { + visible: choice.done + width: parent.width + horizontalAlignment: Text.AlignHCenter + textFormat: Text.PlainText + text: choice.state === "keyed" ? Commons.I18n.t("agents.keySaved") : Commons.I18n.t("agents.signedInShort") + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: choice.available ? Qt.PointingHandCursor : Qt.ArrowCursor + onEntered: root.pointAt("choice", choice.index) + onClicked: root.chooseAddProvider(choice.provider.providerId) + } } } + } + } + } - MouseArea { - anchors.fill: parent - hoverEnabled: true - cursorShape: choice.available ? Qt.PointingHandCursor : Qt.ArrowCursor - onEntered: root.pointAt("choice", choice.index) - onClicked: root.chooseAddProvider(choice.modelData.providerId) - } + // Key: paste the provider's API key; it is saved where its collector + // reads it, and checked against the provider once. + Column { + visible: root.addStage === "key" + width: parent.width + spacing: Style.space(10) - } + Text { + width: parent.width + text: Commons.I18n.t("agents.pasteKey").arg(root.addProviderName(root.addProvider)) + textFormat: Text.PlainText + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } + + TextField { + id: addKeyField + width: parent.width + password: true + placeholderText: Commons.I18n.t("agents.placeholderKey") + foreground: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + onVisibleChanged: if (visible) { text = ""; forceActiveFocus() } + onAccepted: { root.startKey(text); text = "" } + Keys.onEscapePressed: function(event) { root.cancelAdd(); event.accepted = true } + } + + TextLink { + visible: !!(root.addProviderEntry(root.addProvider) || {}).keyUrl + text: Commons.I18n.t("agents.getKey") + onClicked: root.openKeyPage() } } From 534eeb495934b78f017ee612321089f60d1f0dd9 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 24 Oct 2026 16:12:26 -0400 Subject: [PATCH 02/11] Read the MiniMax key pasted into the agents panel Co-Authored-By: Claude Opus 5.5 --- bin/omarchy-agent-usage-minimax | 14 +++++++++++--- test/shell.d/agent-usage-minimax-test.sh | 11 +++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-agent-usage-minimax b/bin/omarchy-agent-usage-minimax index f58c0e78ab0..93691bffb2f 100755 --- a/bin/omarchy-agent-usage-minimax +++ b/bin/omarchy-agent-usage-minimax @@ -5,7 +5,9 @@ """Collect MiniMax usage into a display-ready JSON record. The 5-hour session and weekly windows come from MiniMax's Token Plan API with -the key MiniMax's own `mmx` CLI, opencode, or pi saved. Each credential's last +MINIMAX_API_KEY, the key pasted into the agents panel (`apiKey` and an +optional `"region": "global"|"cn"` in ~/.config/omarchy/agents/minimax.json), +or the key MiniMax's own `mmx` CLI, opencode, or pi saved. Each credential's last good limits are cached, so a failed check keeps showing them, marked stale, rather than dropping the section. @@ -34,6 +36,7 @@ from collectors import ( # noqa: E402 Stats, base_record as shared_base_record, cached_scan, + config_home, data_home, emit, open_json, @@ -54,8 +57,8 @@ AGENT_ID = "minimax" AGENT_NAME = "MiniMax" DEFAULT_BASE_URL = "https://api.minimax.io" CN_BASE_URL = "https://api.minimaxi.com" -AUTH_HELP = "Set MINIMAX_API_KEY, or sign in to MiniMax in opencode, to show Token Plan limits." -AUTH_HELP_BAD_KEY = "MiniMax rejected the saved credentials. Set MINIMAX_API_KEY, or sign in to MiniMax again with `mmx` or opencode." +AUTH_HELP = "Add a MiniMax API key in the agents panel (~/.config/omarchy/agents/minimax.json), set MINIMAX_API_KEY, or sign in to MiniMax in opencode, to show Token Plan limits." +AUTH_HELP_BAD_KEY = "MiniMax rejected the saved credentials. Add a current key in the agents panel (~/.config/omarchy/agents/minimax.json), set MINIMAX_API_KEY, or sign in to MiniMax again with `mmx` or opencode." # The plan the quota endpoint describes: only a Token Plan has these windows. TIER_LABEL = "Token Plan" # The pi/omp and opencode provider names MiniMax bills, by region. Exact @@ -168,6 +171,11 @@ def credentials() -> tuple[str, str]: if env_key: return env_key, override or (CN_BASE_URL if os.environ.get("MINIMAX_REGION") == "cn" else DEFAULT_BASE_URL) + # The key pasted into the agents panel, on the region saved beside it. + saved = read_json(config_home() / "omarchy" / "agents" / "minimax.json") + if saved_api_key := str(saved.get("apiKey") or "").strip(): + return saved_api_key, override or (CN_BASE_URL if saved.get("region") == "cn" else DEFAULT_BASE_URL) + config = mmx_config() if mmx_key := config_key(config): return mmx_key, override or config_base_url(config) diff --git a/test/shell.d/agent-usage-minimax-test.sh b/test/shell.d/agent-usage-minimax-test.sh index aef22253d22..07da94175d8 100755 --- a/test/shell.d/agent-usage-minimax-test.sh +++ b/test/shell.d/agent-usage-minimax-test.sh @@ -178,6 +178,17 @@ expect("MiniMax asks MINIMAX_BASE_URL when it is set", credentials(), ["mmx_env" del os.environ["MINIMAX_API_KEY"], os.environ["MINIMAX_BASE_URL"] expect("MiniMax asks with mmx's own key on the host mmx names", credentials(), ["mmx_saved", "https://api.minimaxi.com"]) +# The key pasted into the agents panel outranks mmx's, on the region saved +# beside it, and an exported key outranks it. +panel = write_json(config_home() / "omarchy" / "agents" / "minimax.json", {"apiKey": "mmx_panel"}) +expect("MiniMax asks with the key pasted into the panel ahead of mmx's", credentials(), ["mmx_panel", "https://api.minimax.io"]) +write_json(panel, {"apiKey": "mmx_panel", "region": "cn"}) +expect("MiniMax asks with the pasted key on the region saved with it", credentials(), ["mmx_panel", "https://api.minimaxi.com"]) +os.environ["MINIMAX_API_KEY"] = "mmx_env" +expect("MiniMax asks with an exported key ahead of the pasted one", credentials()[0], "mmx_env") +del os.environ["MINIMAX_API_KEY"] +panel.unlink() + # Wherever the host comes from, the key never goes over plain HTTP to another # machine, nor to a URL that isn't one. for name, setup in ( From b0a3cb4a4e0acaea1b4edf92225a92a382e58c52 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 24 Oct 2026 16:12:26 -0400 Subject: [PATCH 03/11] Read the Synthetic key pasted into the agents panel Co-Authored-By: Claude Opus 5.5 --- bin/omarchy-agent-usage-synthetic | 13 ++++++++++--- test/shell.d/agent-usage-synthetic-test.sh | 3 +++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-agent-usage-synthetic b/bin/omarchy-agent-usage-synthetic index 35a780a75c9..7a5cdead4fa 100755 --- a/bin/omarchy-agent-usage-synthetic +++ b/bin/omarchy-agent-usage-synthetic @@ -9,7 +9,9 @@ synthetic provider, and from Claude Code, MiniMax Code and Codex sessions sent to Synthetic's API. The account's allowances (the rolling five-hour request limit, the weekly credits, search and free tool calls) come from Synthetic's documented GET https://api.synthetic.new/v2/quotas, which doesn't count -against them. The agents panel only ever reads the JSON this prints. +against them, asked with SYNTHETIC_API_KEY, the key pasted into the agents +panel (`apiKey` in ~/.config/omarchy/agents/synthetic.json), or the one pi, +omp or opencode saved. The agents panel only ever reads the JSON this prints. """ import argparse @@ -30,6 +32,7 @@ from collectors import ( # noqa: E402 Stats, base_record, cached_scan, + config_home, data_home, emit, number, @@ -51,8 +54,8 @@ AGENT_ID = "synthetic" AGENT_NAME = "Synthetic" PROVIDER = "synthetic" API_BASE_URL = "https://api.synthetic.new" -AUTH_HELP = "Set SYNTHETIC_API_KEY, or sign in to Synthetic in pi, omp or opencode, to read its quotas." -REFUSED_HELP = "Synthetic rejected the API key. Set a current one in SYNTHETIC_API_KEY or sign in to Synthetic again." +AUTH_HELP = "Add a Synthetic API key in the agents panel (~/.config/omarchy/agents/synthetic.json), set SYNTHETIC_API_KEY, or sign in to Synthetic in pi, omp or opencode, to read its quotas." +REFUSED_HELP = "Synthetic rejected the API key. Add a current one in the agents panel (~/.config/omarchy/agents/synthetic.json) or SYNTHETIC_API_KEY, or sign in to Synthetic again." def finite(value): @@ -141,6 +144,10 @@ def api_key(): explicit = str(os.environ.get("SYNTHETIC_API_KEY", "")).strip() if explicit: return explicit + # The key pasted into the agents panel, ahead of any other agent's store. + saved = str(read_json(config_home() / "omarchy" / "agents" / "synthetic.json").get("apiKey") or "").strip() + if saved: + return saved # The default pi and omp sign-ins, then opencode's, then each named profile's: # a profile's sessions are counted, so a sign-in made only there must read # its quotas too, without outranking the defaults. diff --git a/test/shell.d/agent-usage-synthetic-test.sh b/test/shell.d/agent-usage-synthetic-test.sh index 60b9e1a210c..1a18ef6b4db 100755 --- a/test/shell.d/agent-usage-synthetic-test.sh +++ b/test/shell.d/agent-usage-synthetic-test.sh @@ -102,6 +102,9 @@ expect("Synthetic resolves pi's $ENV key", key_used(PI_SYNTHETIC="pi-key"), "Bea write_json(home() / "moved" / "auth.json", {"synthetic": {"key": "moved-key"}}) expect("Synthetic follows PI_CODING_AGENT_DIR to pi's key", key_used(PI_CODING_AGENT_DIR=str(home() / "moved")), "Bearer moved-key") expect("Synthetic prefers SYNTHETIC_API_KEY to every stored key", key_used(SYNTHETIC_API_KEY="env-key"), "Bearer env-key") +write_json(config_home() / "omarchy" / "agents" / "synthetic.json", {"apiKey": "panel-key"}) +expect("Synthetic prefers the key pasted into the panel to pi's", key_used(PI_SYNTHETIC="pi-key"), "Bearer panel-key") +expect("and SYNTHETIC_API_KEY to the pasted key", key_used(SYNTHETIC_API_KEY="env-key"), "Bearer env-key") # ---------------------------------------------------------------- contract From 9f134a3e8ed1ec51d7a1f03daeae077e8f53ab7a Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 24 Oct 2026 16:12:26 -0400 Subject: [PATCH 04/11] Read the DeepSeek key pasted into the agents panel Co-Authored-By: Claude Opus 5.5 --- bin/omarchy-agent-usage-deepseek | 17 ++++++++++------- .../agent-usage-deepseek-balance-test.sh | 5 +++++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/bin/omarchy-agent-usage-deepseek b/bin/omarchy-agent-usage-deepseek index 7f30bfc7430..ad3063d0329 100755 --- a/bin/omarchy-agent-usage-deepseek +++ b/bin/omarchy-agent-usage-deepseek @@ -8,9 +8,11 @@ Local stats come from pi/omp session files whose assistant messages ran on the deepseek provider, from opencode sessions that ran on a deepseek provider, and from DeepSeek Harness (dsh) session logs on its DeepSeek routes. DeepSeek billing is prepaid, so the record carries the live credit balance from -GET https://api.deepseek.com/user/balance when a key is available, plus the -optional funded/spent detail derived from ~/.config/omarchy/agents/deepseek.json -(the same shape the fireworks collector uses). The agents panel only ever reads +GET https://api.deepseek.com/user/balance when a key is available +(DEEPSEEK_API_KEY, then the key pasted into the agents panel, `apiKey` in +~/.config/omarchy/agents/deepseek.json, then the agents that keep one), plus +the optional funded/spent detail derived from that same file (the shape the +fireworks collector uses). The agents panel only ever reads the JSON this prints. """ @@ -212,13 +214,14 @@ def read_dotenv_key(path: Path) -> str: def api_key() -> str: - # The environment first, then the agents that keep a DeepSeek key, read - # only: pi and omp, opencode, and dsh's managed store with its `.env` - # fallback. + # The environment first, then the key pasted into the agents panel, then + # the agents that keep a DeepSeek key, read only: pi and omp, opencode, and + # dsh's managed store with its `.env` fallback. env_key = str(os.environ.get("DEEPSEEK_API_KEY", "")).strip() if env_key: return env_key for read in ( + lambda: str(read_config().get("apiKey") or "").strip(), lambda: read_json_key(pi_agent_dir() / "auth.json", "deepseek"), lambda: read_json_key(Path.home() / ".omp" / "agent" / "auth.json", "deepseek"), lambda: read_json_key(data_home() / "opencode" / "auth.json", "deepseek"), @@ -267,7 +270,7 @@ def fetch_balance(api_base_url: str, key: str) -> dict[str, Any]: payload = open_json(request, timeout=15) except HttpError as error: if error.status in (401, 403): - raise DeepSeekError("DeepSeek rejected the saved API key. Set a current one in DEEPSEEK_API_KEY or the agent that holds it.", refused=True) from error + raise DeepSeekError("DeepSeek rejected the saved API key. Add a current one in the agents panel (~/.config/omarchy/agents/deepseek.json), DEEPSEEK_API_KEY, or the agent that holds it.", refused=True) from error if error.status: raise DeepSeekError(f"DeepSeek's balance endpoint returned HTTP {error.status}.") from error if error.transport: diff --git a/test/shell.d/agent-usage-deepseek-balance-test.sh b/test/shell.d/agent-usage-deepseek-balance-test.sh index 0ec0a434f54..09df971e5e0 100755 --- a/test/shell.d/agent-usage-deepseek-balance-test.sh +++ b/test/shell.d/agent-usage-deepseek-balance-test.sh @@ -166,6 +166,11 @@ opencode_auth(None, "deepseek", "key-opencode") expect("DeepSeek collector reads the key opencode saved", saved_key(), "key-opencode") pi_auth(home() / ".omp" / "agent", "deepseek", "key-omp") expect("DeepSeek collector prefers omp's key to opencode's", saved_key(), "key-omp") +write_json(config_home() / "omarchy" / "agents" / "deepseek.json", {"apiKey": "key-panel", "fundedAmount": 50}) +expect("DeepSeek collector prefers the key pasted into the panel to pi's", saved_key(), "key-panel") +os.environ["DEEPSEEK_API_KEY"] = "key-env" +expect("and DEEPSEEK_API_KEY to the pasted key", saved_key(), "key-env") +del os.environ["DEEPSEEK_API_KEY"] # The balance is kept per API host: another host, the same key or not, is # another account, and never shows this one's balance. From 4abace66b9390571bea50227d940c3500e127fe8 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 24 Oct 2026 16:12:26 -0400 Subject: [PATCH 05/11] Read the Fireworks key pasted into the agents panel Co-Authored-By: Claude Opus 5.5 --- bin/omarchy-agent-usage-fireworks | 10 +++++++--- test/shell.d/agent-usage-fireworks-scanner-test.sh | 5 +++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-agent-usage-fireworks b/bin/omarchy-agent-usage-fireworks index fa16c3d24f5..1c1c3e463ac 100755 --- a/bin/omarchy-agent-usage-fireworks +++ b/bin/omarchy-agent-usage-fireworks @@ -8,7 +8,9 @@ Token stats come from the Fireworks billing API grouped by day and model for the last 30 days. Fireworks does not expose its prepaid ledger, so the record carries an estimated balance instead of rate limits: the credits configured in ~/.config/omarchy/agents/fireworks.json minus rated account costs since the -funding date. The agents panel only ever reads the JSON this prints. +funding date. The API key is FIREWORKS_API_KEY, then the one pasted into the +agents panel (`apiKey` in that same fireworks.json), then firectl's, pi's or +opencode's. The agents panel only ever reads the JSON this prints. Each API key keeps its account's last good usage rows and balance under the agent-usage cache, so a failed check shows them stale rather than blank. @@ -250,10 +252,12 @@ def read_config() -> dict[str, Any]: def credentials(auth_path: Path, config: dict[str, Any]) -> tuple[str, str]: file_key, file_account = read_auth_file(auth_path) - # Agent logins are the fallback ladder below an explicit key or a firectl - # login: pi first as Omarchy's default agent, opencode as the last resort. + # Agent logins are the fallback ladder below an explicit key, the key + # pasted into the agents panel, or a firectl login: pi first as Omarchy's + # default agent, opencode as the last resort. api_key = ( str(os.environ.get("FIREWORKS_API_KEY", "")).strip() + or str(config.get("apiKey") or "").strip() or file_key or read_pi_key(pi_auth_path()) or read_opencode_key(opencode_auth_path()) diff --git a/test/shell.d/agent-usage-fireworks-scanner-test.sh b/test/shell.d/agent-usage-fireworks-scanner-test.sh index 8aeadbaedc4..837c41e230e 100755 --- a/test/shell.d/agent-usage-fireworks-scanner-test.sh +++ b/test/shell.d/agent-usage-fireworks-scanner-test.sh @@ -339,4 +339,9 @@ expect("pi's $VAR keys resolve, and a missing one stays unresolved", [module.resolve_pi_key("$FW_TEST_ENV_KEY"), module.resolve_pi_key("$FW_DEFINITELY_MISSING")], ["fw_from_env", ""]) pi_auth(None, "fireworks", "!secret-tool lookup fw") expect("pi's !command keys are skipped for the next rung", module.credentials(missing, {})[0], "fw_opencode") +pasted = module.credentials(auth, {"apiKey": "fw_panel"})[0] +os.environ["FIREWORKS_API_KEY"] = "fw_env" +expect("the key pasted into the panel comes below FIREWORKS_API_KEY and above firectl", + [pasted, module.credentials(auth, {"apiKey": "fw_panel"})[0]], ["fw_panel", "fw_env"]) +del os.environ["FIREWORKS_API_KEY"] PY From 90403bb4351ea6f7a03120ec47b4c193839989df Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 24 Oct 2026 16:12:26 -0400 Subject: [PATCH 06/11] Read the OpenCode key pasted into the agents panel Co-Authored-By: Claude Opus 5.5 --- bin/omarchy-agent-usage-opencode | 14 ++++++++++---- test/shell.d/agent-usage-opencode-limits-test.sh | 3 +++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/bin/omarchy-agent-usage-opencode b/bin/omarchy-agent-usage-opencode index 315118933d6..e2d3383fad9 100755 --- a/bin/omarchy-agent-usage-opencode +++ b/bin/omarchy-agent-usage-opencode @@ -8,8 +8,10 @@ Local stats come from pi/omp sessions that ran through an opencode provider and from opencode's own message database, both of its stores. Only the Zen and Go providers count: opencode runs on Anthropic or OpenAI belong to the claude and codex records. The limits are the Go plan's rolling, weekly, and -monthly meters from OpenCode's usage endpoint. The agents panel only ever -reads the JSON this prints. +monthly meters from OpenCode's usage endpoint, asked with OPENCODE_API_KEY or +OPENCODE_GO_API_KEY, the key pasted into the agents panel (`apiKey` in +~/.config/omarchy/agents/opencode.json), then the keys opencode, pi and omp +keep. The agents panel only ever reads the JSON this prints. """ import argparse @@ -34,6 +36,7 @@ from collectors import ( # noqa: E402 base_record, cache_root, # noqa: F401 - the tests reach the cache through it cached_scan, + config_home, data_home, emit, limits_cache_path as shared_limits_cache_path, @@ -41,6 +44,7 @@ from collectors import ( # noqa: E402 open_json, opencode_db, pi_session_roots, + read_json, scan_max_age, scan_opencode, scan_pi_sessions, @@ -198,7 +202,8 @@ def read_store_credentials(db): def credential_candidates(): - # An explicit key wins, then opencode's own credential stores, then pi's: + # An explicit key wins, then the key pasted into the agents panel, then + # opencode's own credential stores, then pi's: # pi drives the same Zen subscription and records it under the same # provider ids, so a machine that codes through pi never signs the opencode # CLI in. The Console session is the implicit one, so it comes last. Which @@ -206,6 +211,7 @@ def credential_candidates(): # hands over to the next. store_keys, console = read_store_credentials(opencode_db()) tokens = [str(os.environ.get(name, "")).strip() for name in ("OPENCODE_API_KEY", "OPENCODE_GO_API_KEY")] + tokens.append(str(read_json(config_home() / "omarchy" / "agents" / "opencode.json").get("apiKey") or "").strip()) tokens += store_keys tokens.append(read_auth_key(data_home() / "opencode" / "auth.json")) tokens.append(read_auth_key(pi_agent_dir() / "auth.json")) @@ -287,7 +293,7 @@ def probe_limits(credential): # A refused credential may just be the wrong one for this account, so the # next way in still gets its turn. if error.status == 401: - return {"ok": False, "refused": True, "helpText": "OpenCode rejected the saved key. Reconnect OpenCode Go with /connect in the TUI, or set OPENCODE_API_KEY."} + return {"ok": False, "refused": True, "helpText": "OpenCode rejected the saved key. Reconnect OpenCode Go with /connect in the TUI, add a current key in the agents panel (~/.config/omarchy/agents/opencode.json), or set OPENCODE_API_KEY."} if error.status == 403: # A key without a Go plan behind it: Zen pay-as-you-go has no meters, # which is no trouble at all. diff --git a/test/shell.d/agent-usage-opencode-limits-test.sh b/test/shell.d/agent-usage-opencode-limits-test.sh index 4a386b98fbc..bc6ceaa5389 100644 --- a/test/shell.d/agent-usage-opencode-limits-test.sh +++ b/test/shell.d/agent-usage-opencode-limits-test.sh @@ -181,6 +181,9 @@ write_json(auth, {"opencode-go": " plain_key "}) expect("OpenCode collector reads a plain-string auth.json key", candidates(), ["plain_key"]) os.environ["OPENCODE_GO_API_KEY"] = "go_env" expect("OpenCode collector reads OPENCODE_GO_API_KEY first", candidates(), ["go_env", "plain_key"]) +write_json(config_home() / "omarchy" / "agents" / "opencode.json", {"apiKey": "panel_key"}) +expect("OpenCode collector asks the key pasted into the panel after the environment's and before opencode's", + candidates(), ["go_env", "panel_key", "plain_key"]) sandbox() pi_auth(None, "opencode-go", "from_pi") From ee596f5e01f63d7181bc451eb2194ab9f7ca193c Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 24 Oct 2026 16:12:26 -0400 Subject: [PATCH 07/11] Ask with OpenRouter's saved apiKey right after OPENROUTER_API_KEY Co-Authored-By: Claude Opus 5.5 --- bin/omarchy-agent-usage-openrouter | 15 ++++++++------- .../agent-usage-openrouter-scanner-test.sh | 5 +++++ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/bin/omarchy-agent-usage-openrouter b/bin/omarchy-agent-usage-openrouter index dc9f5c776a6..f06853d86e2 100755 --- a/bin/omarchy-agent-usage-openrouter +++ b/bin/omarchy-agent-usage-openrouter @@ -12,9 +12,10 @@ opencode SQLite DB (providerID == "openrouter", role == "assistant") and from pi/omp session files whose assistant messages ran on the openrouter provider, read-only. Each key keeps its own last good meter and balance, shown stale when a -check fails. The key is OPENROUTER_API_KEY, opencode's or pi/omp's saved -OpenRouter login, or the config file's. Without any, the local stats still -show and the record waits for auth. +check fails. The key is OPENROUTER_API_KEY, the one pasted into the agents +panel (`apiKey` in ~/.config/omarchy/agents/openrouter.json), or opencode's +or pi/omp's saved OpenRouter login. Without any, the local stats still show +and the record waits for auth. Tier 2 (management key): when OPENROUTER_MANAGEMENT_KEY (or the management key in the config file) is set, the last-30-days token history comes from @@ -80,7 +81,7 @@ from collectors import ( # noqa: E402 AGENT_ID = "openrouter" AGENT_NAME = "OpenRouter" PROVIDER = "openrouter" -AUTH_HELP = "Set OPENROUTER_API_KEY, or sign in to OpenRouter in opencode, pi or omp. For the 30-day history, add a management key as OPENROUTER_MANAGEMENT_KEY (openrouter.ai/settings/management-keys)." +AUTH_HELP = "Set OPENROUTER_API_KEY, add a key in the agents panel (~/.config/omarchy/agents/openrouter.json), or sign in to OpenRouter in opencode, pi or omp. For the 30-day history, add a management key as OPENROUTER_MANAGEMENT_KEY (openrouter.ai/settings/management-keys)." MANAGEMENT_AUTH_HELP = "Replace OPENROUTER_MANAGEMENT_KEY (or managementKey in ~/.config/omarchy/agents/openrouter.json) with a working one from openrouter.ai/settings/management-keys." API_BASE = "https://openrouter.ai/api/v1" @@ -121,14 +122,14 @@ def read_pi_key(path: Path) -> str: def api_key(config: dict[str, Any]) -> str: - # Explicit key or an opencode, pi or omp login wins; the config file is - # the last resort for headless/refresh-timer runs without any. + # An explicit key wins, then the key pasted into the agents panel, then + # an opencode, pi or omp login. return ( str(os.environ.get("OPENROUTER_API_KEY", "")).strip() + or str(config.get("apiKey") or "").strip() or read_opencode_key(data_home() / "opencode" / "auth.json") or read_pi_key(pi_agent_dir() / "auth.json") or read_pi_key(Path.home() / ".omp" / "agent" / "auth.json") - or str(config.get("apiKey") or "").strip() ) diff --git a/test/shell.d/agent-usage-openrouter-scanner-test.sh b/test/shell.d/agent-usage-openrouter-scanner-test.sh index a12067cff19..09d3542c981 100755 --- a/test/shell.d/agent-usage-openrouter-scanner-test.sh +++ b/test/shell.d/agent-usage-openrouter-scanner-test.sh @@ -197,6 +197,11 @@ expect("pi's and omp's saved OpenRouter key is used", saved + [openrouter.asked, opencode_auth(None, "openrouter", "sk-opencode") record = openrouter() expect("opencode's saved OpenRouter key comes before pi's", openrouter.asked, ["/credits=sk-opencode", "/key=sk-opencode"], record) +pasted = module.api_key({"apiKey": "sk-panel"}) +os.environ["OPENROUTER_API_KEY"] = "sk-env" +expect("the key pasted into the panel comes before opencode's, and OPENROUTER_API_KEY before it", + [pasted, module.api_key({"apiKey": "sk-panel"})], ["sk-panel", "sk-env"]) +del os.environ["OPENROUTER_API_KEY"] # A "management" key that /activity refuses (an inference key, or one # revoked) falls back to the local scan: machine numbers with prompt counts, From 9f6f4fef9d8b99a51630f939b133a0ba91351923 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 24 Oct 2026 16:22:02 -0400 Subject: [PATCH 08/11] Sign in to more agents and save API keys from the account add flow omarchy-agent-account-add now signs in Cursor, Copilot (through gh), Kimi, Muse, Antigravity and Devin, and saves a pasted API key for the key-only providers, checking it with the provider's usage collector. --check lists every provider's state from files alone, without starting a lazy CLI. Co-Authored-By: Claude Opus 5.5 --- bin/omarchy-agent-account-add | 428 ++++++++++++++++++++++++++--- default/i18n/en/cli.json | 7 + test/shell.d/agent-account-test.sh | 259 ++++++++++++++++- 3 files changed, 662 insertions(+), 32 deletions(-) diff --git a/bin/omarchy-agent-account-add b/bin/omarchy-agent-account-add index 6f92d7bd5f0..a9871421974 100755 --- a/bin/omarchy-agent-account-add +++ b/bin/omarchy-agent-account-add @@ -1,26 +1,31 @@ #!/bin/bash -# omarchy:summary=Sign in to a Claude, Codex, or Grok subscription -# omarchy:args=[--check] [--events] [--reauth ] [claude|codex|grok] [label] -# omarchy:examples=omarchy agent account add claude | omarchy agent account add Work | omarchy agent account add codex Side +# omarchy:summary=Sign in to a coding agent subscription, or save a provider's API key +# omarchy:args=[--check] [--events] [--reauth ] [--key ] [claude|codex|grok|cursor|copilot|kimi|muse|agy|devin] [label] +# omarchy:examples=omarchy agent account add claude | omarchy agent account add Work | omarchy agent account add codex Side | omarchy agent account add cursor | omarchy agent account add --key openrouter 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 ` 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 +# sign-in, an additional account, already signed in, or a key to paste. +# --events is for the agents panel: progress arrives as +# `@@omarchy ` lines mixed into the CLI's own output (which +# carries the URLs and codes the panel shows), nothing waits on a terminal it +# doesn't open itself, and the result also lands as a notification in case # the panel has closed. # --reauth signs an account that's already here in again, in its # own home, for when its sign-in has lapsed. +# --key saves the API key read from stdin for a provider that +# signs in with one alone. check=false events=false +key_mode=false reauth="" while [[ ${1:-} == --* ]]; do case "$1" in --check) check=true ;; --events) events=true ;; + --key) key_mode=true ;; --reauth) reauth="${2:-}" shift @@ -30,37 +35,185 @@ while [[ ${1:-} == --* ]]; do shift done +usage() { + echo "Usage: omarchy-agent-account-add [--check] [--events] [--reauth ] [claude|codex|grok|cursor|copilot|kimi|muse|agy|devin] [label]" >&2 + echo " omarchy-agent-account-add [--events] --key " >&2 + exit 1 +} + +account_providers=(claude codex grok) +signin_providers=(cursor copilot kimi muse agy devin) +key_providers=(zai minimax synthetic ollama openrouter deepseek fireworks opencode kimchi) + +config_home=${XDG_CONFIG_HOME:-$HOME/.config} + # Whether a provider's own home (~/.claude, ~/.codex, ~/.grok) is signed in. primary_signed_in() { omarchy-agent-account-state list "$1" | jq -e '.[0].accounts | any(.primary and .signedIn)' >/dev/null } +# Whether a JSON file holds a non-empty string at a path. +json_has() { + [[ -s $1 ]] && jq -e "$2 | strings | length > 0" "$1" >/dev/null 2>&1 +} + +# Who gh is signed in to github.com as, read the way the Copilot collector +# reads hosts.yml: the host's own `user:` key, as plain lines. +gh_signed_in() { + local hosts="${GH_CONFIG_DIR:-$config_home/gh}/hosts.yml" + [[ -f $hosts ]] || return 1 + awk ' + /^[[:space:]]*(#|$)/ { next } + { + match($0, /^ */) + indent = RLENGTH + line = $0 + sub(/^[[:space:]]+/, "", line) + sub(/[[:space:]]+$/, "", line) + } + indent == 0 { in_host = (line == "github.com:"); child = -1; next } + !in_host { next } + child < 0 { child = indent } + indent == child && line ~ /^user:/ { + sub(/^user:[[:space:]]*/, "", line) + gsub(/^["\047]+|["\047]+$/, "", line) + if (line != "") { found = 1; exit } + } + END { exit !found } + ' "$hosts" +} + +kimi_credentials() { + if [[ -n ${KIMI_CODE_HOME:-} ]]; then + echo "$KIMI_CODE_HOME/credentials/kimi-code.json" + elif [[ ! -f $HOME/.kimi-code/credentials/kimi-code.json && -f ${KIMI_SHARE_DIR:-$HOME/.kimi}/credentials/kimi-code.json ]]; then + echo "${KIMI_SHARE_DIR:-$HOME/.kimi}/credentials/kimi-code.json" + else + echo "$HOME/.kimi-code/credentials/kimi-code.json" + fi +} + +agy_token_file() { + echo "${AGY_DIR:-$HOME/.gemini/antigravity-cli}/antigravity-oauth-token" +} + +# Beside the CLI's data directory, as the Devin collector looks for it. +devin_credentials() { + local data_dir="${DEVIN_DATA_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/devin/cli}" + data_dir=${data_dir%/} + echo "${data_dir%/*}/credentials.toml" +} + +# Whether a sign-in agent is signed in where its usage collector looks. Only +# files and the keyring are read: every CLI here is a lazy mise shim, and +# running one could start an install. +signed_in() { + case "$1" in + cursor) json_has "${CURSOR_AUTH_PATH:-$config_home/cursor/auth.json}" '.accessToken' ;; + copilot) + # An exported token counts, as it does for the collector. + local tokens="${COPILOT_QUOTA_TOKEN:-}${GH_TOKEN:-}${GITHUB_TOKEN:-}" + [[ -n ${tokens//[[:space:]]/} ]] || gh_signed_in + ;; + kimi) json_has "$(kimi_credentials)" '.access_token' ;; + muse) json_has "${MUSE_AUTH_PATH:-$config_home/muse/auth.json}" '.providers.meta.access_token' ;; + agy) + [[ -s $(agy_token_file) ]] || [[ -n $(timeout 3 secret-tool lookup service gemini username antigravity 2>/dev/null) ]] + ;; + devin) [[ -s $(devin_credentials) ]] ;; + *) return 1 ;; + esac +} + +# Where a key provider's pasted key is saved: Kimchi's own config, which its +# CLI's login writes, and an Omarchy file of the provider's for the rest. +key_file() { + if [[ $1 == "kimchi" ]]; then + # Kimchi and its collector both read ~/.config, whatever XDG_CONFIG_HOME says. + echo "$HOME/.config/kimchi/config.json" + else + echo "$config_home/omarchy/agents/$1.json" + fi +} + if [[ $check == "true" ]]; then - for p in claude codex grok; do + for p in "${account_providers[@]}"; do if primary_signed_in "$p"; then echo "$p additional" else echo "$p first" fi done + for p in "${signin_providers[@]}"; do + if signed_in "$p"; then + echo "$p signed" + else + echo "$p first" + fi + done + for p in "${key_providers[@]}"; do + if json_has "$(key_file "$p")" '.apiKey // .api_key'; then + echo "$p keyed" + else + echo "$p key" + 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" "$@" +known() { + local wanted=$1 list + shift + for list in "$@"; do + [[ $list == "$wanted" ]] && return 0 + done + return 1 +} + +if known "${1:-}" "${key_providers[@]}"; then + kind="key" +elif [[ $key_mode == "true" ]]; then + usage +elif known "${1:-}" "${signin_providers[@]}"; then + [[ -z $reauth ]] || usage + kind="signin" +else + kind="account" + # The provider can be left out when it's your default agent: + # `omarchy agent account add Work`. + if ! known "${1:-}" "${account_providers[@]}"; then + default=$(omarchy-default-agent) + known "$default" "${account_providers[@]}" && set -- "$default" "$@" + fi fi -case "${1:-}" in -claude) provider="claude"; name="Claude"; site="claude.ai"; package="claude"; home_env="CLAUDE_CONFIG_DIR" ;; -codex) provider="codex"; name="Codex"; site="chatgpt.com"; package="codex"; home_env="CODEX_HOME" ;; -grok) provider="grok"; name="Grok"; site="x.ai"; package="grok"; home_env="GROK_HOME" ;; -*) - echo "Usage: omarchy-agent-account-add [--check] [--events] [--reauth ] [claude|codex|grok] [label]" >&2 - exit 1 +provider="${1:-}" +package="" +command="$provider" +case "$provider" in +claude) name="Claude"; site="claude.ai"; package="claude"; home_env="CLAUDE_CONFIG_DIR" ;; +codex) name="Codex"; site="chatgpt.com"; package="codex"; home_env="CODEX_HOME" ;; +grok) name="Grok"; site="x.ai"; package="grok"; home_env="GROK_HOME" ;; +cursor) name="Cursor"; command="cursor-agent"; package="cursor-agent"; agent_id="cursor-agent"; collectors=(cursor grokbot) ;; +copilot) name="GitHub Copilot"; command="gh"; package="gh"; agent_id="copilot"; collectors=(copilot) ;; +kimi) name="Kimi"; package="npm:@moonshot-ai/kimi-code"; agent_id="kimi"; collectors=(kimi) ;; +muse) name="Muse"; package="muse"; agent_id="muse"; collectors=(muse) ;; +agy) name="Antigravity"; package="antigravity-cli"; agent_id="agy"; collectors=(agy) ;; +devin) + name="Devin"; agent_id="devin"; collectors=(devin) + # Cognition publishes per-platform archives behind a manifest of the current release. + package="http:devin[platforms_linux_x64_url=https://static.devin.ai/cli/{{ version }}/devin-{{ version }}-x86_64-unknown-linux.tar.gz,platforms_linux_arm64_url=https://static.devin.ai/cli/{{ version }}/devin-{{ version }}-aarch64-unknown-linux.tar.gz,bin_path=bin,version_list_url=https://static.devin.ai/cli/current/manifest.json,version_json_path=.version]" ;; +zai) name="Z.ai"; key_env=(ZAI_API_KEY ZHIPU_API_KEY ZHIPUAI_API_KEY) ;; +minimax) name="MiniMax"; key_env=(MINIMAX_API_KEY) ;; +synthetic) name="Synthetic"; key_env=(SYNTHETIC_API_KEY) ;; +ollama) name="Ollama Cloud"; key_env=(OLLAMA_API_KEY) ;; +openrouter) name="OpenRouter"; key_env=(OPENROUTER_API_KEY) ;; +deepseek) name="DeepSeek"; key_env=(DEEPSEEK_API_KEY) ;; +fireworks) name="Fireworks"; key_env=(FIREWORKS_API_KEY) ;; +opencode) name="OpenCode"; key_env=(OPENCODE_API_KEY OPENCODE_GO_API_KEY) ;; +kimchi) name="Kimchi"; key_env=(KIMCHI_API_KEY) ;; +*) usage ;; esac label="${2:-}" @@ -93,12 +246,25 @@ fail() { } # Cancelling from the panel stops the CLI's login too, and leaves no -# half-made home behind. +# half-made home behind. A key that was never confirmed goes back to what +# was there before it. home="" browser="" +scratch="" +key_saved="" +key_backup="" cleanup() { [[ -n $browser ]] && rm -f "$browser" [[ -n $home && -d $home && $home == */.pending/* ]] && rm -rf "$home" + if [[ -n $key_saved ]]; then + if [[ -n $key_backup ]]; then + mv -f "$key_backup" "$key_saved" + else + rm -f "$key_saved" + fi + fi + [[ -n $key_backup ]] && rm -f "$key_backup" + [[ -n $scratch ]] && rm -f "$scratch" } trap cleanup EXIT trap 'pkill -TERM -P $$; exit 130' TERM INT @@ -112,15 +278,221 @@ interruptible() { wait $! } -if omarchy-cmd-missing "$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. +adopt_default() { + if [[ -z $(omarchy-default-agent) ]]; then + mkdir -p "$HOME/.config/omarchy/defaults" + printf '%s\n' "$1" >"$HOME/.config/omarchy/defaults/agent" + fi +} + +# ------------------------------------------------------------------- API keys + +if [[ $kind == "key" ]]; then + if [[ $events == "false" && -t 0 ]]; then + key=$(gum input --password --placeholder "API key" --prompt "$name API key: ") || exit 130 + else + key="" + IFS= read -r key || true + fi + # Whitespace around a pasted key is never part of it. + key=${key//$'\r'/} + key=${key#"${key%%[![:space:]]*}"} + key=${key%"${key##*[![:space:]]}"} + [[ -n $key ]] || fail "$(omarchy-i18n agents.keyEmpty)" + + # Saved with only the owner able to read it, merged into whatever else the + # file holds (a funded amount, a region, a management key), and swapped in + # whole so nothing ever reads half a file. + previous_umask=$(umask) + umask 077 + file=$(key_file "$provider") + dir=${file%/*} + mkdir -p "$dir" || fail "$(omarchy-i18n agents.keySaveFailed "$name")" + merge=() + if [[ -s $file ]]; then + jq -e 'type == "object"' "$file" >/dev/null 2>&1 || fail "$(omarchy-i18n agents.keyUnreadable "$file")" + merge=(--slurpfile saved "$file") + fi + scratch=$(mktemp "$dir/.${file##*/}.XXXXXX") || fail "$(omarchy-i18n agents.keySaveFailed "$name")" + # The key reaches jq on stdin, never as an argument anyone could list. + if (( ${#merge[@]} > 0 )); then + printf '%s' "$key" | jq -Rs "${merge[@]}" '$saved[0] + {apiKey: .}' >"$scratch" + else + printf '%s' "$key" | jq -Rs '{apiKey: .}' >"$scratch" + fi || fail "$(omarchy-i18n agents.keySaveFailed "$name")" + unset key + chmod 600 "$scratch" || fail "$(omarchy-i18n agents.keySaveFailed "$name")" + if [[ -e $file ]]; then + key_backup=$(mktemp "$dir/.${file##*/}.previous.XXXXXX") || fail "$(omarchy-i18n agents.keySaveFailed "$name")" + cp -p "$file" "$key_backup" || fail "$(omarchy-i18n agents.keySaveFailed "$name")" + fi + key_saved=$file + mv -f "$scratch" "$file" || fail "$(omarchy-i18n agents.keySaveFailed "$name")" + scratch="" + umask "$previous_umask" + + # The collector asks the provider with the key just saved: the provider's + # own variables are left out so an exported key can't answer for it. Only + # a refusal undoes the save; a provider that can't be reached says nothing + # about the key. + say "$(omarchy-i18n agents.keyChecking "$name")" + scratch=$(mktemp "${XDG_RUNTIME_DIR:-/tmp}/omarchy-account-key.XXXXXX") || fail "$(omarchy-i18n agents.keySaveFailed "$name")" + unset_env=() + for variable in "${key_env[@]}"; do + unset_env+=(-u "$variable") + done + env "${unset_env[@]}" timeout 30 "omarchy-agent-usage-$provider" --force >"$scratch" 2>/dev/null /dev/null) == "Sign-in expired" ]]; then + fail "$(omarchy-i18n agents.keyRejected "$name")" + fi + + key_saved="" + omarchy-agent-usage-update --force "$provider" >/dev/null 2>&1 & + finish "$(omarchy-i18n agents.keySaved "$name")" + exit 0 +fi + +install_cli() { + omarchy-cmd-missing "$command" || return 0 say "$(omarchy-i18n agents.installingCli "$name")" if [[ $events == "true" ]]; then - mise use -g "$package" >/dev/null 2>&1 || fail "$(omarchy-i18n agents.installCliFailed "$name")" + exec 3>/dev/null else - mise use -g "$package" || exit 1 + exec 3>&1 + fi + # An npm-backed CLI needs node, which mise won't install as its dependency + # while it's missing. + if [[ $package == npm:* ]] && ! mise where node &>/dev/null; then + mise install node >&3 2>&3 || fail "$(omarchy-i18n agents.installCliFailed "$name")" + fi + mise use -g "$package" >&3 2>&3 || fail "$(omarchy-i18n agents.installCliFailed "$name")" + exec 3>&- +} + +# --------------------------------------------------------- other sign-ins + +# Signing in to an agent that keeps a single sign-in, where its CLI always +# looks, through your normal browser. +if [[ $kind == "signin" ]]; then + install_cli + + # Echoes the CLI's output as it comes, and opens the first https URL in it + # once, for a CLI that only prints where to sign in. + open_first_url() { + local line opened=false + while IFS= read -r line; do + printf '%s\n' "$line" + if [[ $opened == "false" && $line =~ (https://[^[:space:]\"\'\<\>]+) ]]; then + opened=true + omarchy-launch-browser "${BASH_REMATCH[1]}" >/dev/null 2>&1 & + fi + done + } + + # The CLI's own output reaches the panel, stderr too (Kimi writes its + # whole login there), with its URL opened when the CLI opens none. + # Both the CLI and the filter are this script's own children, so Cancel + # stops them both. + run_login() { + local open=$1 filter="" status + shift + if [[ $open == "open" ]]; then + exec 4> >(open_first_url) + filter=$! + "$@" <&0 >&4 2>&1 & + exec 4>&- + else + "$@" <&0 2>&1 & + fi + wait $! + status=$? + # The filter finishes printing what the CLI wrote last. + [[ -n $filter ]] && wait "$filter" + return "$status" + } + + signed_in_now() { + if [[ $provider == "copilot" ]]; then + # gh writes hosts.yml; a token the environment exports says nothing + # about this login. + gh_signed_in + else + signed_in "$provider" + fi + } + + # What the sign-in looks like now, so a new one can be told from the one + # already there. Fingerprints only: nothing here is printed. + credential_stamp() { + case "$provider" in + agy) + stat -c '%Y %s' "$(agy_token_file)" 2>/dev/null + timeout 3 secret-tool lookup service gemini username antigravity 2>/dev/null | sha256sum + ;; + devin) stat -c '%Y %s' "$(devin_credentials)" 2>/dev/null ;; + esac + } + + case "$provider" in + cursor | kimi | muse | copilot) + say "$(omarchy-i18n agents.signInBrowser "$name")" + case "$provider" in + cursor) run_login keep cursor-agent login ;; + kimi) run_login keep kimi login ;; + muse) run_login open muse login ;; + copilot) + # Copilot's CLI and its usage collector both ask as gh's sign-in. An + # exported token would make gh refuse to sign in at all. + run_login open env -u GH_TOKEN -u GITHUB_TOKEN gh auth login --web --hostname github.com --git-protocol https + ;; + esac + login_status=$? + if (( login_status != 0 )) || ! signed_in_now; then + fail "$(omarchy-i18n agents.signInFailed "$name")" + fi + ;; + agy | devin) + # Neither signs in without a terminal: agy walks its sign-in in its own + # TUI, and devin's login refuses to run without one. The terminal takes + # it from here, and this waits for the sign-in to land. + case "$provider" in + agy) login_command=(agy) ;; + devin) login_command=(devin auth login) ;; + esac + before=$(credential_stamp) + say "$(omarchy-i18n agents.signInTerminal "$name")" + omarchy-launch-floating-terminal-with-presentation "${login_command[@]}" /dev/null 2>&1 & + waited=0 + until signed_in_now && [[ $(credential_stamp) != "$before" ]]; do + if (( waited >= 900 )); then + fail "$(omarchy-i18n agents.signInFailed "$name")" + fi + sleep 2 & + wait $! + (( waited += 2 )) + done + ;; + esac + + # Copilot's usage collector asks GitHub only where the Copilot CLI has a + # home; signing in to Copilot here says it's used. + if [[ $provider == "copilot" ]]; then + mkdir -p "${COPILOT_HOME:-$HOME/.copilot}" fi + adopt_default "$agent_id" + omarchy-agent-usage-update --force "${collectors[@]}" >/dev/null 2>&1 & + finish "$(omarchy-i18n agents.signedIn "$name")" + exit 0 fi +# ------------------------------------------------------------- accounts + +install_cli + # 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. @@ -171,13 +543,7 @@ fi if ! primary_signed_in "$provider"; then say "$(omarchy-i18n agents.signInBrowser "$name")" 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 + adopt_default "$provider" omarchy-agent-usage-update --force "$provider" >/dev/null 2>&1 & finish "$(omarchy-i18n agents.signedIn "$name")" else diff --git a/default/i18n/en/cli.json b/default/i18n/en/cli.json index 349a2ef5f45..4aefe30bdf8 100644 --- a/default/i18n/en/cli.json +++ b/default/i18n/en/cli.json @@ -7,6 +7,12 @@ "agents.homeFailed": "Couldn't set up a home for the new account.", "agents.installCliFailed": "Installing the %1 CLI didn't finish.", "agents.installingCli": "Installing the %1 CLI…", + "agents.keyChecking": "Checking the key with %1…", + "agents.keyEmpty": "No API key was pasted.", + "agents.keyRejected": "%1 rejected the key.", + "agents.keySaveFailed": "Couldn't save the %1 API key.", + "agents.keySaved": "Saved the %1 API key.", + "agents.keyUnreadable": "%1 isn't valid JSON, so the key wasn't saved there.", "agents.loginUnfinished": "The login didn't finish, so no account was added.", "agents.newSessionsUse": "New %1 sessions now use %2", "agents.noAccountNamed": "No %1 account named %2.", @@ -21,6 +27,7 @@ "agents.signInBrowser": "Sign in to %1 in the browser window that opens.", "agents.signInFailed": "The %1 sign-in didn't finish.", "agents.signInPrivate": "Sign in as the account you're adding in the private window that opens.", + "agents.signInTerminal": "Finish signing in to %1 in the terminal window that opens.", "agents.signedIn": "Signed in to %1.", "agents.signedInAgain": "Signed in to %1 again.", "agents.stayingOn": "Staying on %1.", diff --git a/test/shell.d/agent-account-test.sh b/test/shell.d/agent-account-test.sh index 4de7a5795b5..c96c30eede5 100644 --- a/test/shell.d/agent-account-test.sh +++ b/test/shell.d/agent-account-test.sh @@ -87,6 +87,13 @@ cat >"$mock_bin/omarchy-launch-browser" <<'SH' printf '%s\n' "$*" >>"$OMARCHY_TEST_BROWSER_LOG" SH +# Antigravity keeps its sign-in in the keyring; this one holds whatever the +# test puts there, never the developer's. +cat >"$mock_bin/secret-tool" <<'SH' +#!/bin/bash +[[ $* == "lookup service gemini username antigravity" ]] && printf '%s' "${OMARCHY_TEST_AGY_SECRET:-}" +SH + chmod +x "$mock_bin"/* export HOME="$test_tmp/home" @@ -99,6 +106,10 @@ export OMARCHY_TEST_BROWSER_LOG="$test_tmp/browser" # a run from inside one (an agent working on Omarchy) must not inherit it. unset CLAUDE_CONFIG_DIR CODEX_HOME GROK_HOME BROWSER unset OMARCHY_AGENT_CLAUDE_HOME OMARCHY_AGENT_CODEX_HOME OMARCHY_AGENT_GROK_HOME +# Where the other agents keep their sign-ins and keys follows from HOME alone. +unset XDG_CONFIG_HOME XDG_DATA_HOME GH_CONFIG_DIR GH_TOKEN GITHUB_TOKEN COPILOT_QUOTA_TOKEN COPILOT_HOME +unset CURSOR_AUTH_PATH MUSE_AUTH_PATH KIMI_CODE_HOME KIMI_SHARE_DIR AGY_DIR DEVIN_DATA_DIR +while read -r variable; do unset "$variable"; done < <(compgen -e | grep '_API_KEY$' || true) accounts="$XDG_STATE_HOME/omarchy/agents/accounts" @@ -343,7 +354,7 @@ pass "a Grok settings cache of the wrong shape reads as no plan" # ------------------------------------------------------------ panel add flow -[[ $(omarchy-agent-account-add --check) == $'claude additional\ncodex additional\ngrok additional' ]] || +[[ $(omarchy-agent-account-add --check | sed -n 1,3p) == $'claude additional\ncodex additional\ngrok additional' ]] || 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" @@ -436,3 +447,249 @@ 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" + +# ------------------------------------------------------- other agents' sign-ins + +# A machine with nothing signed in yet. Every other agent's CLI is a lazy +# mise shim, so each stub logs being run: --check must never start one. +export HOME="$test_tmp/fresh" +export XDG_STATE_HOME="$test_tmp/fresh-state" +mkdir -p "$HOME" +lazy_log="$test_tmp/lazy" +: >"$lazy_log" +export OMARCHY_TEST_LAZY_LOG="$lazy_log" + +cat >"$mock_bin/cursor-agent" <<'SH' +#!/bin/bash +echo "cursor-agent $*" >>"$OMARCHY_TEST_LAZY_LOG" +[[ $* == "login" ]] || exit 1 +echo "If your browser didn't open, use this link:" +echo "https://cursor.com/loginDeepControl?challenge=abc" +mkdir -p "$HOME/.config/cursor" +echo '{"accessToken":"cursor-token"}' >"$HOME/.config/cursor/auth.json" +SH + +# Kimi writes its whole login to stderr. +cat >"$mock_bin/kimi" <<'SH' +#!/bin/bash +echo "kimi $*" >>"$OMARCHY_TEST_LAZY_LOG" +[[ $* == "login" ]] || exit 1 +echo "Opening browser for Kimi device login: https://www.kimi.com/code/authorize_device?user_code=5VO0-EJ1G" >&2 +echo "If the browser did not open, paste the URL above and enter code: 5VO0-EJ1G" >&2 +mkdir -p "$HOME/.kimi-code/credentials" +echo '{"access_token":"kimi-token"}' >"$HOME/.kimi-code/credentials/kimi-code.json" +SH + +# Muse opens no browser at all. +cat >"$mock_bin/muse" <<'SH' +#!/bin/bash +echo "muse $*" >>"$OMARCHY_TEST_LAZY_LOG" +[[ $* == "login" ]] || exit 1 +if [[ -n ${OMARCHY_TEST_LOGIN_HANGS:-} ]]; then + echo $$ >"$OMARCHY_TEST_LOGIN_HANGS" + exec sleep 30 +fi +echo "Open this page to sign in:" +echo " https://auth.meta.com/oauth/device/?code=PSBL-LCKQ" +echo "confirm this code matches:" +echo " PSBL-LCKQ" +echo "Or https://auth.meta.com/elsewhere" +mkdir -p "$HOME/.config/muse" +echo '{"providers":{"meta":{"access_token":"muse-token"}}}' >"$HOME/.config/muse/auth.json" +SH + +# gh without a terminal prints its device code and URL and opens nothing. +cat >"$mock_bin/gh" <<'SH' +#!/bin/bash +echo "gh $* GH_TOKEN=${GH_TOKEN-unset}" >>"$OMARCHY_TEST_LAZY_LOG" +[[ ${1:-} == "auth" && ${2:-} == "login" ]] || exit 1 +echo "! First copy your one-time code: 9AC3-7061" >&2 +echo "Open this URL to continue in your web browser: https://github.com/login/device" >&2 +mkdir -p "$HOME/.config/gh" +printf 'github.com:\n git_protocol: https\n user: octocat\n' >"$HOME/.config/gh/hosts.yml" +SH + +for cli in agy devin; do + printf '#!/bin/bash\necho "%s $*" >>"$OMARCHY_TEST_LAZY_LOG"\nexit 1\n' "$cli" >"$mock_bin/$cli" +done + +# agy and devin sign in only in a terminal of their own; this one lands the +# sign-in a moment after it opens, unless the test leaves it waiting. +cat >"$mock_bin/omarchy-launch-floating-terminal-with-presentation" <<'SH' +#!/bin/bash +echo "$*" >>"$OMARCHY_TEST_TERMINAL_LOG" +[[ -n ${OMARCHY_TEST_TERMINAL_HANGS:-} ]] && exit 0 +case "$1" in +agy) file="$HOME/.gemini/antigravity-cli/antigravity-oauth-token" ;; +devin) file="$HOME/.local/share/devin/credentials.toml" ;; +esac +(sleep 1; mkdir -p "${file%/*}"; echo '{"token":{"access_token":"t"}}' >"$file") & +SH +export OMARCHY_TEST_TERMINAL_LOG="$test_tmp/terminal" + +chmod +x "$mock_bin"/* + +expected=$'claude first\ncodex first\ngrok first\ncursor first\ncopilot first\nkimi first\nmuse first\nagy first\ndevin first' +expected+=$'\nzai key\nminimax key\nsynthetic key\nollama key\nopenrouter key\ndeepseek key\nfireworks key\nopencode key\nkimchi key' +[[ $(omarchy-agent-account-add --check) == "$expected" ]] || + fail "--check lists every provider, in order, on a machine with nothing signed in" "$(omarchy-agent-account-add --check)" +[[ $(OMARCHY_TEST_AGY_SECRET='{"token":{}}' omarchy-agent-account-add --check | grep '^agy ') == "agy signed" ]] || + fail "--check reads Antigravity's sign-in from the keyring" +[[ ! -s $lazy_log ]] || fail "--check never runs an agent's CLI" "$(cat "$lazy_log")" +pass "--check lists every provider without running any agent's CLI" + +: >"$OMARCHY_TEST_BROWSER_LOG" +OMARCHY_TEST_DEFAULT_AGENT="" omarchy-agent-account-add --events cursor "$test_tmp/cursor-output" 2>/dev/null || + fail "a Cursor sign-in succeeds" "$(cat "$test_tmp/cursor-output")" +grep -qx "@@omarchy status Sign in to Cursor in the browser window that opens." "$test_tmp/cursor-output" && + grep -qx "https://cursor.com/loginDeepControl?challenge=abc" "$test_tmp/cursor-output" && + grep -qx "@@omarchy done Signed in to Cursor." "$test_tmp/cursor-output" || + fail "a Cursor sign-in reports its progress and the CLI's own link" "$(cat "$test_tmp/cursor-output")" +[[ ! -s $OMARCHY_TEST_BROWSER_LOG ]] || fail "Cursor opens its own sign-in page" "$(cat "$OMARCHY_TEST_BROWSER_LOG")" +[[ $(cat "$HOME/.config/omarchy/defaults/agent") == "cursor-agent" ]] || + fail "the first agent signed in on a machine with no default becomes the default, by its launcher's name" +pass "Cursor signs in through its own login" + +omarchy-agent-account-add --events kimi "$test_tmp/kimi-output" 2>/dev/null +grep -q "enter code: 5VO0-EJ1G" "$test_tmp/kimi-output" && grep -qx "@@omarchy done Signed in to Kimi." "$test_tmp/kimi-output" || + fail "what Kimi writes to stderr reaches the panel" "$(cat "$test_tmp/kimi-output")" +[[ $(cat "$HOME/.config/omarchy/defaults/agent") == "cursor-agent" ]] || fail "a later sign-in leaves the default alone" +pass "Kimi's sign-in reaches the panel on stdout" + +: >"$OMARCHY_TEST_BROWSER_LOG" +omarchy-agent-account-add --events muse "$test_tmp/muse-output" 2>/dev/null +grep -qx " PSBL-LCKQ" "$test_tmp/muse-output" && grep -qx "@@omarchy done Signed in to Muse." "$test_tmp/muse-output" || + fail "a Muse sign-in passes its code through" "$(cat "$test_tmp/muse-output")" +[[ $(cat "$OMARCHY_TEST_BROWSER_LOG") == "https://auth.meta.com/oauth/device/?code=PSBL-LCKQ" ]] || + fail "Muse's sign-in page is opened for it, once" "$(cat "$OMARCHY_TEST_BROWSER_LOG")" +pass "Muse's sign-in page is opened for it" + +: >"$OMARCHY_TEST_BROWSER_LOG" +: >"$lazy_log" +GH_TOKEN=exported omarchy-agent-account-add --events copilot "$test_tmp/copilot-output" 2>/dev/null +grep -q "one-time code: 9AC3-7061" "$test_tmp/copilot-output" && grep -qx "@@omarchy done Signed in to GitHub Copilot." "$test_tmp/copilot-output" || + fail "a Copilot sign-in passes gh's device code through" "$(cat "$test_tmp/copilot-output")" +[[ $(cat "$lazy_log") == "gh auth login --web --hostname github.com --git-protocol https GH_TOKEN=unset" ]] || + fail "Copilot signs in through gh, without an exported token in the way" "$(cat "$lazy_log")" +[[ $(cat "$OMARCHY_TEST_BROWSER_LOG") == "https://github.com/login/device" && -d $HOME/.copilot ]] || + fail "gh's device page is opened for it, and Copilot's usage gets asked for" "$(cat "$OMARCHY_TEST_BROWSER_LOG")" +pass "Copilot signs in through gh" + +for agent in agy:Antigravity:agy devin:Devin:"devin auth login"; do + IFS=: read -r id agent_name terminal_command <<<"$agent" + : >"$OMARCHY_TEST_TERMINAL_LOG" + omarchy-agent-account-add --events "$id" "$test_tmp/$id-output" 2>/dev/null + grep -qx "@@omarchy status Finish signing in to $agent_name in the terminal window that opens." "$test_tmp/$id-output" && + grep -qx "@@omarchy done Signed in to $agent_name." "$test_tmp/$id-output" || + fail "$agent_name signs in in a terminal and is done once its sign-in lands" "$(cat "$test_tmp/$id-output")" + [[ $(cat "$OMARCHY_TEST_TERMINAL_LOG") == "$terminal_command" ]] || + fail "$agent_name's sign-in runs in a terminal" "$(cat "$OMARCHY_TEST_TERMINAL_LOG")" +done +pass "Antigravity and Devin sign in in a terminal, and the panel waits for it" + +: >"$lazy_log" +expected=$'cursor signed\ncopilot signed\nkimi signed\nmuse signed\nagy signed\ndevin signed' +[[ $(omarchy-agent-account-add --check | sed -n 4,9p) == "$expected" ]] || + fail "--check sees each sign-in where its usage collector reads it" "$(omarchy-agent-account-add --check)" +[[ ! -s $lazy_log ]] || fail "--check never runs an agent's CLI once signed in either" "$(cat "$lazy_log")" +pass "--check sees each sign-in where its usage collector reads it" + +# Cancelling stops a wait on the terminal at once, and a login whose page the +# script opened, with the CLI itself. +rm -f "$HOME/.local/share/devin/credentials.toml" +OMARCHY_TEST_TERMINAL_HANGS=1 omarchy-agent-account-add --events devin "$test_tmp/devin-cancel" 2>&1 & +adding=$! +sleep 1 +started=$EPOCHREALTIME +kill -TERM "$adding" +wait "$adding" || true +# Well inside the two seconds between looks at the sign-in. +now=$EPOCHREALTIME +(( ${now//[.,]/} - ${started//[.,]/} < 800000 )) || fail "cancelling stops waiting on the terminal at once" +! grep -q "^@@omarchy done" "$test_tmp/devin-cancel" || fail "a cancelled terminal sign-in isn't done" + +OMARCHY_TEST_LOGIN_HANGS="$test_tmp/muse.pid" omarchy-agent-account-add --events muse /dev/null 2>&1 & +adding=$! +for _ in {1..20}; do + [[ -s $test_tmp/muse.pid ]] && break + sleep 0.1 +done +kill -TERM "$adding" +wait "$adding" || true +login_pid=$(cat "$test_tmp/muse.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 a login whose page was opened for it" +fi +pass "cancelling stops a terminal wait and a login alike" + +# ------------------------------------------------------------------- API keys + +# The usage collector checks a pasted key with its provider; these report +# whatever the test says the provider answered, and what they were run with. +for id in openrouter kimchi zai; do + cat >"$mock_bin/omarchy-agent-usage-$id" <<'SH' +#!/bin/bash +id=${0##*omarchy-agent-usage-} +printf '%s %s env=%s\n' "$id" "$*" "${OPENROUTER_API_KEY-unset}" >>"$OMARCHY_TEST_COLLECTOR_LOG" +printf '{"usageStatusText":"%s"}\n' "${OMARCHY_TEST_KEY_STATUS:-}" +SH +done +# Every jq run is logged, to show the key never rides on a command line. +cat >"$mock_bin/jq" <>"\$OMARCHY_TEST_JQ_LOG" +exec $(command -v jq) "\$@" +SH +chmod +x "$mock_bin"/* +export OMARCHY_TEST_COLLECTOR_LOG="$test_tmp/collector" OMARCHY_TEST_JQ_LOG="$test_tmp/jq" +: >"$OMARCHY_TEST_JQ_LOG" + +saved="$HOME/.config/omarchy/agents/openrouter.json" +mkdir -p "${saved%/*}" +echo '{"managementKey":"sk-management","apiKey":"sk-old"}' >"$saved" +chmod 644 "$saved" +: >"$notifications" +printf ' sk-or-pasted \n' | OPENROUTER_API_KEY=sk-exported omarchy-agent-account-add --events --key openrouter >"$test_tmp/key-output" 2>&1 || + fail "a pasted key is saved" "$(cat "$test_tmp/key-output")" +grep -qx "@@omarchy status Checking the key with OpenRouter…" "$test_tmp/key-output" && + grep -qx "@@omarchy done Saved the OpenRouter API key." "$test_tmp/key-output" || + fail "saving a key reports its progress" "$(cat "$test_tmp/key-output")" +[[ $(jq -c . "$saved") == '{"managementKey":"sk-management","apiKey":"sk-or-pasted"}' && $(stat -c %a "$saved") == 600 ]] || + fail "a pasted key is merged into the provider's file, readable only by you" "$(stat -c %a "$saved") $(cat "$saved")" +[[ $(cat "$OMARCHY_TEST_COLLECTOR_LOG") == "openrouter --force env=unset" ]] || + fail "the key is checked by its collector, without an exported key answering for it" "$(cat "$OMARCHY_TEST_COLLECTOR_LOG")" +! grep -q "sk-or-pasted" "$test_tmp/key-output" "$notifications" "$OMARCHY_TEST_JQ_LOG" "$OMARCHY_TEST_COLLECTOR_LOG" || + fail "the key is never printed or passed as an argument" +pass "a pasted key is saved beside what the file already holds" + +cp -p "$saved" "$test_tmp/saved-before" +if printf 'sk-or-bad\n' | OMARCHY_TEST_KEY_STATUS="Sign-in expired" omarchy-agent-account-add --events --key openrouter >"$test_tmp/key-rejected" 2>&1; then + fail "a key the provider refuses fails" +fi +grep -qx "@@omarchy error OpenRouter rejected the key." "$test_tmp/key-rejected" || fail "a refused key says so" "$(cat "$test_tmp/key-rejected")" +cmp -s "$saved" "$test_tmp/saved-before" && [[ $(stat -c %a "$saved") == 600 ]] || + fail "a refused key puts back the file that was there" "$(cat "$saved")" +OMARCHY_TEST_KEY_STATUS="Sign-in expired" omarchy-agent-account-add --events --key zai <<<"zai-bad" >/dev/null 2>&1 || true +[[ ! -e $HOME/.config/omarchy/agents/zai.json ]] || fail "a refused first key leaves no file behind" +pass "a refused key leaves the saved one as it was" + +if omarchy-agent-account-add --events --key zai <<<" " >"$test_tmp/key-empty" 2>&1; then + fail "an empty key is refused" +fi +grep -qx "@@omarchy error No API key was pasted." "$test_tmp/key-empty" && [[ ! -e $HOME/.config/omarchy/agents/zai.json ]] || + fail "an empty key is refused and saves nothing" "$(cat "$test_tmp/key-empty")" +pass "an empty key is refused" + +XDG_CONFIG_HOME="$test_tmp/elsewhere" omarchy-agent-account-add --events --key kimchi <<<"kimchi-key" >/dev/null 2>&1 || + fail "a Kimchi key is saved" +[[ $(jq -r .apiKey "$HOME/.config/kimchi/config.json") == "kimchi-key" ]] || fail "a Kimchi key is saved in Kimchi's own config" +pass "a Kimchi key is saved where Kimchi reads it" + +[[ $(omarchy-agent-account-add --check | sed -n '10p;14p;18p') == $'zai key\nopenrouter keyed\nkimchi keyed' ]] || + fail "--check tells a provider with a saved key from one without" "$(omarchy-agent-account-add --check)" +pass "--check tells a provider with a saved key from one without" From 300fb68c64659f1703e61b9d5a51de0f12c34a56 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 24 Oct 2026 16:22:56 -0400 Subject: [PATCH 09/11] Document where the agents panel saves a pasted API key Co-Authored-By: Claude Opus 5.5 --- docs/agent-usage-collectors.md | 1 + shell/plugins/agents/README.md | 14 +++++++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/agent-usage-collectors.md b/docs/agent-usage-collectors.md index 387028042fc..94ff8cae06a 100644 --- a/docs/agent-usage-collectors.md +++ b/docs/agent-usage-collectors.md @@ -13,6 +13,7 @@ New collectors build on the shared module, [`shell/plugins/agents/collectors.py` - `--force` (a person pressing refresh) reuses nothing. - The record is printed on stdout. Diagnostics go to stderr and never carry a credential. - A collector never writes to an agent's own files. Credentials are only read, and a token is refreshed only where the agent's own CLI does the same under its lock, never by rewriting another harness's store. +- A key-only provider (an API key, no sign-in) reads the key pasted into the panel's add-account screen, which `omarchy-agent-account-add --key ` saves as `apiKey` in `~/.config/omarchy/agents/.json` (Kimchi's goes to Kimchi's own `~/.config/kimchi/config.json` instead). It comes right after the provider's own environment variables and before any other agent's or CLI's store, so a pasted key is the one asked with unless one is exported (Z.ai's `zai.json` predates this and outranks its environment variables too). The file can hold other fields a person wrote by hand (a funded amount, a region, a management key), so the writer merges into it. ## The shared module diff --git a/shell/plugins/agents/README.md b/shell/plugins/agents/README.md index 581e3852243..0a752cf8315 100644 --- a/shell/plugins/agents/README.md +++ b/shell/plugins/agents/README.md @@ -204,7 +204,7 @@ There is no meter. Alibaba shows a Coding Plan's 5-hour, weekly, and monthly req One record covers the OpenCode subscription, Zen and Go together: usage on the `opencode` and `opencode-go` providers. opencode sessions on an Anthropic or OpenAI provider stay in the `claude` and `codex` records, so nothing counts twice. Both of opencode's message stores are read (`message` and V2's `session_message`, deduplicated by message id, leaving out the copies a fork makes), from the database `OPENCODE_DB` names when set. -The Go meters read `OPENCODE_API_KEY` or `OPENCODE_GO_API_KEY` first, then the keys opencode keeps in its database and `auth.json`, then pi's (under `PI_CODING_AGENT_DIR` when set), then omp's, and last the OpenCode Console session, which asks Console's own endpoint with its workspace id. A refused credential hands over to the next one, and when every one is refused the record asks for a new sign-in. A rate-limited window reads as full. A key without a Go plan (Zen pay-as-you-go) is no trouble: the panel shows local token usage alone, and that key isn't asked again for an hour. Without any key, local usage shows the same way. +The Go meters read `OPENCODE_API_KEY` or `OPENCODE_GO_API_KEY` first, then the key pasted into the panel's add-account screen (`apiKey` in `~/.config/omarchy/agents/opencode.json`), then the keys opencode keeps in its database and `auth.json`, then pi's (under `PI_CODING_AGENT_DIR` when set), then omp's, and last the OpenCode Console session, which asks Console's own endpoint with its workspace id. A refused credential hands over to the next one, and when every one is refused the record asks for a new sign-in. A rate-limited window reads as full. A key without a Go plan (Zen pay-as-you-go) is no trouble: the panel shows local token usage alone, and that key isn't asked again for an hour. Without any key, local usage shows the same way. ### Z.ai @@ -212,7 +212,7 @@ Z.ai limits need a key that carries a GLM Coding Plan. A pay-as-you-go key repor ### Ollama Cloud -Ollama Cloud reads `OLLAMA_API_KEY` first, then `apiKey` in `~/.config/omarchy/agents/ollama.json`, then the key pi or omp signed in with in `~/.pi/agent/auth.json` (or under `PI_CODING_AGENT_DIR`) or `~/.omp/agent/auth.json`. Each key keeps its own last good limits and plan. +Ollama Cloud reads `OLLAMA_API_KEY` first, then `apiKey` in `~/.config/omarchy/agents/ollama.json` (where the panel's add-account screen saves a pasted key), then the key pi or omp signed in with in `~/.pi/agent/auth.json` (or under `PI_CODING_AGENT_DIR`) or `~/.omp/agent/auth.json`. Each key keeps its own last good limits and plan. Ollama's balance endpoint (`/api/balance`) reports a legacy plan's session and weekly windows as the percent remaining with their reset times, and a current plan's included credits as a balance against the monthly allowance, shown as an Included credits meter that resets when the period ends. Credits bought on top of the plan (`purchased.balance_usd`, the unexpired ones left) show as a balance of what remains; the answer says nothing of how much was bought, so it raises no low-balance alarm. The limits cover the whole account — every model the key can reach — while the local stats count pi, omp, and opencode sessions on the `ollama-cloud` provider, and cloud-tagged models (`glm-4.6:cloud`, `gpt-oss:120b-cloud`) reached through the local daemon's `ollama` provider, which run on Ollama's servers too, from Codex's `--oss` rollouts as well (with the prompts typed in them, and any model on a Codex provider named `ollama-cloud`), and Claude Code turns sent to `ollama.com` or answered by a cloud-tagged model, by the [Claude Code](#claude-code) rule. To count only the Claude models Ollama Cloud serves, set a model prefix in `~/.config/omarchy/agents/ollama.json`: @@ -227,11 +227,11 @@ Ollama's balance endpoint (`/api/balance`) reports a legacy plan's session and w ### MiniMax -MiniMax reads `MINIMAX_API_KEY` first, then the API key or OAuth session saved by MiniMax's own `mmx` CLI if you use it, then the key opencode, pi, or omp stores when MiniMax is signed in there. A key is only ever read from MiniMax's own provider ids, never a look-alike such as a custom gateway. The host follows where the key came from: `mmx`'s own key uses the host `mmx` itself asks (its `base_url`, then its OAuth resource URL, then its region, which `MINIMAX_REGION` overrides), an opencode, pi, or omp key on a `minimax-cn` provider uses the China host, and anything else uses the global `https://api.minimax.io`; `MINIMAX_BASE_URL` overrides them all. The quota request never follows a redirect, so the key stays with MiniMax's host. The windows are the `general` quota's (or the first non-media model's), skipping any model mmx would call not in plan; a window MiniMax marks exhausted reads as full whatever percentage it still carries, and an unlimited one shows no meter. Local stats count MiniMax Code's own sessions under `MINIMAX_DATA_DIR`, `MAVIS_DATA_DIR`, or `~/.minimax` (user messages as prompts, assistant replies as tokens), only replies on MiniMax's own routes (`minimax` and `minimax_api`) and the prompts they answered; a reply on one of MiniMax Code's custom providers (`custom_provider:`, its Z.AI and OpenRouter presets included) is billed by whoever runs that provider's `options.baseURL` in `config.yaml`, so it counts here at MiniMax's own host, under Z.ai, Ollama Cloud, DeepSeek, Kimi or OpenRouter at theirs, under Local models on this machine or the LAN, and nowhere for a gateway that can't be placed or a provider gone from the config (the current config decides for every earlier reply too). pi, omp, and opencode sessions on a MiniMax provider count too. Without a key the panel still shows that local usage and waits for one to show the limits. +MiniMax reads `MINIMAX_API_KEY` first, then the key pasted into the panel's add-account screen (`apiKey` in `~/.config/omarchy/agents/minimax.json`, with an optional `"region": "cn"` for the China host), then the API key or OAuth session saved by MiniMax's own `mmx` CLI if you use it, then the key opencode, pi, or omp stores when MiniMax is signed in there. A key is only ever read from MiniMax's own provider ids, never a look-alike such as a custom gateway. The host follows where the key came from: `mmx`'s own key uses the host `mmx` itself asks (its `base_url`, then its OAuth resource URL, then its region, which `MINIMAX_REGION` overrides), an opencode, pi, or omp key on a `minimax-cn` provider uses the China host, and anything else uses the global `https://api.minimax.io`; `MINIMAX_BASE_URL` overrides them all. The quota request never follows a redirect, so the key stays with MiniMax's host. The windows are the `general` quota's (or the first non-media model's), skipping any model mmx would call not in plan; a window MiniMax marks exhausted reads as full whatever percentage it still carries, and an unlimited one shows no meter. Local stats count MiniMax Code's own sessions under `MINIMAX_DATA_DIR`, `MAVIS_DATA_DIR`, or `~/.minimax` (user messages as prompts, assistant replies as tokens), only replies on MiniMax's own routes (`minimax` and `minimax_api`) and the prompts they answered; a reply on one of MiniMax Code's custom providers (`custom_provider:`, its Z.AI and OpenRouter presets included) is billed by whoever runs that provider's `options.baseURL` in `config.yaml`, so it counts here at MiniMax's own host, under Z.ai, Ollama Cloud, DeepSeek, Kimi or OpenRouter at theirs, under Local models on this machine or the LAN, and nowhere for a gateway that can't be placed or a provider gone from the config (the current config decides for every earlier reply too). pi, omp, and opencode sessions on a MiniMax provider count too. Without a key the panel still shows that local usage and waits for one to show the limits. ### OpenRouter -OpenRouter reads `OPENROUTER_API_KEY` first, then the key opencode stores in `~/.local/share/opencode/auth.json`, then the key pi (`~/.pi/agent/auth.json`, honoring `PI_CODING_AGENT_DIR`) or omp (`~/.omp/agent/auth.json`) saved when OpenRouter is signed in there, then `apiKey` in `~/.config/omarchy/agents/openrouter.json`. Without any key, the local pi, omp, and opencode usage on the OpenRouter provider still shows, and the panel waits for a key to show the balance and meter. A management key in `OPENROUTER_MANAGEMENT_KEY` (or `managementKey` in that file, from `openrouter.ai/settings/management-keys`) switches the token history from the local scan to the account-global `/activity` API; on its own it also reads the balance, with no key-limit meter. OpenRouter documents `/credits` as management-key only, so an inference key it answers with 403 shows its key-limit meter without a balance and is no sign-in to redo; with a management key as well, that key reads the balance instead. +OpenRouter reads `OPENROUTER_API_KEY` first, then `apiKey` in `~/.config/omarchy/agents/openrouter.json` (where the panel's add-account screen saves a pasted key), then the key opencode stores in `~/.local/share/opencode/auth.json`, then the key pi (`~/.pi/agent/auth.json`, honoring `PI_CODING_AGENT_DIR`) or omp (`~/.omp/agent/auth.json`) saved when OpenRouter is signed in there. Without any key, the local pi, omp, and opencode usage on the OpenRouter provider still shows, and the panel waits for a key to show the balance and meter. A management key in `OPENROUTER_MANAGEMENT_KEY` (or `managementKey` in that file, from `openrouter.ai/settings/management-keys`) switches the token history from the local scan to the account-global `/activity` API; on its own it also reads the balance, with no key-limit meter. OpenRouter documents `/credits` as management-key only, so an inference key it answers with 403 shows its key-limit meter without a balance and is no sign-in to redo; with a management key as well, that key reads the balance instead. The collector reads the prepaid ledger from `GET /api/v1/credits` (`remaining = total_credits - total_usage`) and reports it as a live, non-estimated balance. OpenRouter documents that endpoint as management-key only, so it is asked once per refresh, with the management key when there is one; an inference key alone asks until OpenRouter refuses it that way, which shows no balance rather than a refused key, and then isn't asked again for a day. A key with a credit limit additionally gets a draining key-limit meter from `GET /api/v1/key`, labeled with how often the limit starts over. Each key keeps its own last good meter and balance. @@ -248,11 +248,11 @@ Without a management key, tokens come from the local opencode database (`provide ### DeepSeek -DeepSeek reads `DEEPSEEK_API_KEY` first, then the key pi stores in `~/.pi/agent/auth.json` (under `PI_CODING_AGENT_DIR` when set), then omp's `~/.omp/agent/auth.json`, never a `!command` or `$VAR` entry, then the key opencode stores in its own `auth.json` when DeepSeek is signed in there, and last the key DeepSeek Harness keeps in `$DSH_HOME/.credentials.yaml` (or the `.env` beside it; `$DSH_HOME` defaults to `~/.dsh`). Each key keeps its own last balance. DeepSeek answers one balance per currency (CNY or USD), and the panel shows the one holding credit, or the first when none does. When DeepSeek says the balance no longer covers API calls (`is_available` false), or it is spent, the status reads Out of credits in the urgent color. Local usage also counts dsh's own session logs under `$DSH_HOME/sessions` on its DeepSeek routes (`deepseek-official`, `deepseek-account`, and its pi-ai adapter's `deepseek` route), leaving out the history a forked session inherits, and Claude Code turns sent to `api.deepseek.com` or answered by a `deepseek-*` model, by the [Claude Code](#claude-code) rule. dsh's other pi-ai routes are billed by that provider: its `openrouter` route counts under OpenRouter, and a hand-declared gateway counts nowhere. Its `fundedAmount` estimate lives in `~/.config/omarchy/agents/deepseek.json`, the same shape the fireworks collector uses; without it the panel shows the live remaining credit and skips the funded-versus-spent line. +DeepSeek reads `DEEPSEEK_API_KEY` first, then the key pasted into the panel's add-account screen (`apiKey` in `~/.config/omarchy/agents/deepseek.json`), then the key pi stores in `~/.pi/agent/auth.json` (under `PI_CODING_AGENT_DIR` when set), then omp's `~/.omp/agent/auth.json`, never a `!command` or `$VAR` entry, then the key opencode stores in its own `auth.json` when DeepSeek is signed in there, and last the key DeepSeek Harness keeps in `$DSH_HOME/.credentials.yaml` (or the `.env` beside it; `$DSH_HOME` defaults to `~/.dsh`). Each key keeps its own last balance. DeepSeek answers one balance per currency (CNY or USD), and the panel shows the one holding credit, or the first when none does. When DeepSeek says the balance no longer covers API calls (`is_available` false), or it is spent, the status reads Out of credits in the urgent color. Local usage also counts dsh's own session logs under `$DSH_HOME/sessions` on its DeepSeek routes (`deepseek-official`, `deepseek-account`, and its pi-ai adapter's `deepseek` route), leaving out the history a forked session inherits, and Claude Code turns sent to `api.deepseek.com` or answered by a `deepseek-*` model, by the [Claude Code](#claude-code) rule. dsh's other pi-ai routes are billed by that provider: its `openrouter` route counts under OpenRouter, and a hand-declared gateway counts nowhere. Its `fundedAmount` estimate lives in `~/.config/omarchy/agents/deepseek.json`, the same shape the fireworks collector uses; without it the panel shows the live remaining credit and skips the funded-versus-spent line. ### Fireworks -Fireworks reads `FIREWORKS_API_KEY` and `FIREWORKS_ACCOUNT_ID` first, then `~/.fireworks/auth.ini` (which `firectl set-api-key` creates), then the key pi stores in `~/.pi/agent/auth.json` when Fireworks is signed in there (honoring `PI_CODING_AGENT_DIR`, and pi's literal and `$ENV_VAR` key forms), and finally the key opencode stores in `~/.local/share/opencode/auth.json`. +Fireworks reads `FIREWORKS_API_KEY` and `FIREWORKS_ACCOUNT_ID` first, then the key pasted into the panel's add-account screen and the account id beside it (`apiKey` and `accountId` in `~/.config/omarchy/agents/fireworks.json`), then `~/.fireworks/auth.ini` (which `firectl set-api-key` creates), then the key pi stores in `~/.pi/agent/auth.json` when Fireworks is signed in there (honoring `PI_CODING_AGENT_DIR`, and pi's literal and `$ENV_VAR` key forms), and finally the key opencode stores in `~/.local/share/opencode/auth.json`. The collector first asks the account's `:getBalance` endpoint for the real prepaid ledger. That endpoint exists but is permission-gated, and as of August 2026 no console-issued API key passes it — Fireworks appears to reserve it for the dashboard session. The probe stays because it is cheap and the live figure lights up automatically if Fireworks ever opens it to keys. Until then the collector falls back to estimating the balance from configuration in `~/.config/omarchy/agents/fireworks.json`: @@ -270,7 +270,7 @@ Each API key keeps its account's last good usage rows and balance; another key o ### Synthetic -Synthetic reads `SYNTHETIC_API_KEY` first, then the key pi stores in `~/.pi/agent/auth.json` (honoring `PI_CODING_AGENT_DIR` and `$ENV_VAR` keys), then omp's `~/.omp/agent/auth.json`, then opencode's `auth.json`. The collector asks the documented `GET https://api.synthetic.new/v2/quotas`, which doesn't count against the quotas. The rolling five-hour request limit and the weekly credits regenerate a little at a time, so they show how much is used with no reset time; hourly search and free tool calls reset when Synthetic says they renew. An account without the credit system shows the documented subscription request bucket instead. Local stats count pi and omp sessions and opencode messages on the `synthetic` provider, and through `scan_routed()` the Claude Code, MiniMax Code and Codex usage sent to Synthetic's API at `api.synthetic.new` (its Anthropic-compatible `/anthropic` and OpenAI-compatible `/openai/v1`, as its guides set them up), plus Claude Code replies by an `hf:` or `syn:` model with no `ANTHROPIC_BASE_URL` override. +Synthetic reads `SYNTHETIC_API_KEY` first, then the key pasted into the panel's add-account screen (`apiKey` in `~/.config/omarchy/agents/synthetic.json`), then the key pi stores in `~/.pi/agent/auth.json` (honoring `PI_CODING_AGENT_DIR` and `$ENV_VAR` keys), then omp's `~/.omp/agent/auth.json`, then opencode's `auth.json`. The collector asks the documented `GET https://api.synthetic.new/v2/quotas`, which doesn't count against the quotas. The rolling five-hour request limit and the weekly credits regenerate a little at a time, so they show how much is used with no reset time; hourly search and free tool calls reset when Synthetic says they renew. An account without the credit system shows the documented subscription request bucket instead. Local stats count pi and omp sessions and opencode messages on the `synthetic` provider, and through `scan_routed()` the Claude Code, MiniMax Code and Codex usage sent to Synthetic's API at `api.synthetic.new` (its Anthropic-compatible `/anthropic` and OpenAI-compatible `/openai/v1`, as its guides set them up), plus Claude Code replies by an `hf:` or `syn:` model with no `ANTHROPIC_BASE_URL` override. ### Local models From d70087048ceb15709d3ea1d5692389831df74578 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 24 Oct 2026 16:24:08 -0400 Subject: [PATCH 10/11] Leave a signed-in Antigravity tile inert, as agy has no login to run again Co-Authored-By: Claude Opus 5.5 --- shell/plugins/agents/Panel.qml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/shell/plugins/agents/Panel.qml b/shell/plugins/agents/Panel.qml index 77dea68a450..b9308d39949 100644 --- a/shell/plugins/agents/Panel.qml +++ b/shell/plugins/agents/Panel.qml @@ -247,7 +247,8 @@ Panel { // Subscriptions sign in through their own CLI; the rest take an API key // pasted here, saved where their collector reads it. keyUrl is where the - // provider hands one out. + // provider hands one out. Antigravity has no login command to run again + // once it's signed in. readonly property var addProviders: [ { providerId: "claude", providerName: "Claude Code", kind: "signin" }, { providerId: "codex", providerName: "Codex", kind: "signin" }, @@ -256,7 +257,7 @@ Panel { { providerId: "copilot", providerName: "Copilot", kind: "signin" }, { providerId: "kimi", providerName: "Kimi", kind: "signin" }, { providerId: "muse", providerName: "Muse Code", kind: "signin" }, - { providerId: "agy", providerName: "Antigravity", kind: "signin" }, + { providerId: "agy", providerName: "Antigravity", kind: "signin", signsInAgain: false }, { providerId: "devin", providerName: "Devin", kind: "signin" }, { providerId: "zai", providerName: "Z.ai", kind: "key", keyUrl: "https://z.ai/manage-apikey/apikey-list" }, { providerId: "minimax", providerName: "MiniMax", kind: "key", keyUrl: "https://platform.minimax.io/user-center/basic-information/interface-key" }, @@ -302,9 +303,15 @@ Panel { if (!checkProcess.running) checkProcess.running = true } + function choosable(id) { + var state = addChecks[id] || "" + var entry = addProviderEntry(id) + return state !== "" && !(state === "signed" && entry && entry.signsInAgain === false) + } + function chooseAddProvider(id) { var state = addChecks[id] || "" - if (state === "") return + if (!choosable(id)) return addProvider = id if (state === "additional") addStage = "name" else if (state === "key" || state === "keyed") addStage = "key" @@ -1277,14 +1284,14 @@ Panel { readonly property int index: modelData readonly property var provider: root.addProviders[index] readonly property string state: root.addChecks[provider.providerId] || "" - readonly property bool available: state !== "" + readonly property bool available: root.choosable(provider.providerId) // Signed in or holding a saved key already: picking it again // signs in again or replaces the key. readonly property bool done: state === "signed" || state === "keyed" readonly property bool hasCursor: root.hasKey("choice", index) width: (choiceGrid.width - choiceGrid.columnSpacing * (root.addColumns - 1)) / root.addColumns implicitHeight: choiceBody.implicitHeight + Style.space(12) - opacity: available ? 1.0 : 0.5 + opacity: available || done ? 1.0 : 0.5 onHasCursorChanged: if (hasCursor) root.revealItem(choice) Column { From 5262a7eaa5cffae775dfa7bc0abd97df8e328e6c Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 24 Oct 2026 16:26:00 -0400 Subject: [PATCH 11/11] Size the add screen's groups by their parent, which the delegate can reach Co-Authored-By: Claude Opus 5.5 --- shell/plugins/agents/Panel.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shell/plugins/agents/Panel.qml b/shell/plugins/agents/Panel.qml index b9308d39949..04c99a35249 100644 --- a/shell/plugins/agents/Panel.qml +++ b/shell/plugins/agents/Panel.qml @@ -1257,7 +1257,7 @@ Panel { Column { id: group required property var modelData - width: add.width + width: parent ? parent.width : 0 spacing: Style.space(10) Text {