Files
omnigent-ai--omnigent/omnigent/onboarding/harness_install.py
T
Corey Zumar 046ee1bc59 fix(host): keep the tunnel receive loop responsive during readiness refresh (#4092)
* fix(host): keep the tunnel receive loop responsive during readiness refresh

The host->server tunnel disconnected with `4003 ping timeout`: the periodic
harness-readiness refresh ran inline on the receive loop and could block it for
~60s — two 30s CLI probe subprocesses (`--version` and `auth status`) hanging on
a wedged harness CLI. While blocked, the host never answered the server's
application-level pings, so the server watchdog declared the host dead and
closed the tunnel.

Fix A (host/connect.py): move the readiness refresh into its own task,
`_harness_readiness_loop`, so the receive loop only ever reads frames and
answers pings — a slow probe can no longer stall the keepalive.

Fix B (harness_install.py, harness_readiness.py, codex_native.py): bound
readiness CLI probes to READINESS_CLI_PROBE_TIMEOUT_S (10s, matching goose's
status-probe budget) instead of 30s, so a hung harness CLI fails fast on the
refresh, the startup hello, and Ctrl-C. Setup and launch gating keep the lenient
30s default via behavior-preserving timeout parameters.

Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>

* test: accept the readiness probe timeout kwarg in harness CLI stubs

Readiness now calls harness_cli_installed / harness_cli_logged_in with a timeout= kwarg (READINESS_CLI_PROBE_TIMEOUT_S); update the monkeypatch stubs in the affected suites to accept it so they exercise the same paths.

Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>

* test(host): cover off-loop readiness refresh and bounded CLI probe

Fix A moved the harness-readiness refresh off the tunnel receive loop into
_harness_readiness_loop. Rewrite the three live-host readiness tests to drive
that loop directly: the old versions drove _serve_frames with a fake tunnel
that blocks on recv, which under the pure recv loop never exits and hangs to
the pytest timeout. Add a harness_install test asserting the readiness caller
shortens the CLI probe subprocess timeout while setup/launch keep the 30s
default.

Co-authored-by: Isaac
Signed-off-by: dbczumar <corey.zumar@databricks.com>

---------

Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-08-05 11:43:21 -07:00

1138 lines
51 KiB
Python

"""Harness CLI install + auth operations — shared by ``run`` and ``configure``.
A coding harness is "ready" along two independent axes:
- **configured** — a usable model credential serves its family (resolved via
:func:`omnigent.onboarding.provider_config.default_provider_for_harness`
over the ambient-merged config). That lives in the provider layer.
- **installed** — the harness's CLI binary is on ``PATH``. This module owns
that axis, mirroring how ``ucode`` checks (``shutil.which(binary)``) and the
npm packages it installs.
``omni setup --no-internal-beta`` uses this to mark an uninstalled harness and
offer to ``npm install`` it; the first-run ``omnigent run`` flow uses the
same map so the two surfaces never disagree about what the machine can launch.
This module also owns the per-harness **CLI binary name**, so it is the natural
home for driving each harness's own *subscription login/logout* commands
(:func:`harness_login` / :func:`harness_logout`) — letting ``configure
harnesses`` be the single place a user signs in or out of Claude / Codex rather
than running ``codex login`` / ``claude auth login`` by hand.
The "is the CLI logged in?" verdict (:func:`harness_cli_logged_in`) asks the
CLI itself (``claude auth status`` / ``codex login status`` / ``agy models``)
rather than reading a credential file, because the file location is
**platform-specific**
— Claude Code stores its OAuth tokens in the macOS Keychain (not
``~/.claude/.credentials.json``) on macOS, so a file check would falsely report
"not logged in" right after a successful ``claude auth login``. The CLI's own
status command reads wherever it actually stored the credential, so login
verification is correct on every platform. (Ambient detection in
:mod:`omnigent.onboarding.ambient` is file-based and subprocess-free on
Linux; on macOS it reuses :func:`harness_cli_logged_in` as a Keychain fallback
when the credentials file is absent — see ``ambient._claude_login_detected``.)
"""
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import NamedTuple
from packaging.version import InvalidVersion, Version
from omnigent._platform import resolve_cli_binary
from omnigent.acp_cli_harnesses import ACP_CLI_HARNESSES
from omnigent.harness_install_spec import HarnessInstallSpec, SetupStep
from omnigent.onboarding.provider_config import ANTHROPIC_FAMILY, GEMINI_FAMILY, OPENAI_FAMILY
from omnigent.opencode_native_client import (
OPENCODE_MAX_VERSION_EXCLUSIVE,
OPENCODE_MIN_VERSION,
)
# Pi is not a configure-menu family (the menu is Claude + Codex), but the
# first-run ``run`` flow falls back to it, so it has install metadata too.
PI_KEY = "pi"
# Qwen Code uses npm installation and has login/logout commands similar to
# other coding CLIs. The binary name is ``qwen``.
QWEN_KEY = "qwen"
# Cursor authenticates against its own backend (``cursor-agent login`` /
# ``CURSOR_API_KEY``) with no provider/gateway credential, and ships via a curl
# installer rather than npm — so it carries an ``install_hint``, not a ``package``.
CURSOR_KEY = "cursor"
# Kimi authenticates against Moonshot AI's backend (``kimi login`` OAuth or a
# Moonshot API key), not via the ambient provider config; like Cursor it ships
# via a curl installer rather than npm, so it carries an ``install_hint``.
KIMI_KEY = "kimi"
# Kiro authenticates against its own backend and ships as a standalone native
# installer, not an npm package managed by ``omni setup``.
KIRO_KEY = "kiro"
# Minimum CLI versions for native harnesses where the runtime has a known
# feature floor. These are intentionally conservative: the runtime may
# gracefully degrade on older CLIs, but setup enforces the floor so a user
# isn't surprised by missing behaviour (e.g. policy hooks, non-interactive
# approval, forwarder schema) after launching.
# Sources:
# - codex: native policy hook requires >= 0.129.0
# (`omnigent/codex_native_app_server.py`).
# - pi: non-interactive ``--approve`` override requires >= 0.79.0
# (``omnigent/pi_native.py``).
# - qwen: ``--input-file`` / ``--json-file`` bridge verified on v0.18.1
# (``omnigent/qwen_native_forwarder.py`` / ``docs/QWEN_NATIVE_DESIGN.md``).
# - goose: SQLite forwarder schema verified on Goose 1.38.0
# (``omnigent/goose_native_forwarder.py``).
# - hermes: parent_session_id schema introduced in v0.17.0
# (``omnigent/hermes_native_forwarder.py``).
# - kiro: MCP config schema (``{"mcpServers": ...}``) verified on kiro-cli 2.10.0
# (``omnigent/kiro_native_bridge.py``).
# - claude: `--mcp-config` (required by the native bridge) introduced long
# before 2026-06-01. The first Claude Code release after the cutoff is
# 2.1.161, so use that as the supported floor.
# - codex: native policy hook requires >= 0.129.0, but that shipped before
# 2026-06-01. The first Codex release after the cutoff is 0.137.0.
# - cursor: Cursor's CLI uses ``YYYY.MM.DD[-build]`` date versions. Default
# to the day after 2026-06-01 so we don't support stale pre-June builds.
# - kimi: first ``kimi-cli`` release after 2026-06-01 is 1.47.0
# (https://github.com/MoonshotAI/kimi-cli/blob/main/CHANGELOG.md).
# - hermes: parent_session_id schema was introduced in v0.17.0, but Hermes now
# ships date-tagged releases; the first one after 2026-06-01 is 2026.06.05.
_CODEX_MIN_VERSION = "0.137.0"
_PI_MIN_VERSION = "0.79.0"
_QWEN_MIN_VERSION = "0.18.1"
_GOOSE_MIN_VERSION = "1.38.0"
_HERMES_MIN_VERSION = "2026.06.05"
_KIRO_MIN_VERSION = "2.10.0"
_CLAUDE_MIN_VERSION = "2.1.161"
_CURSOR_MIN_VERSION = "2026.06.02"
_KIMI_MIN_VERSION = "1.47.0"
# OpenCode native harness CLI (``opencode serve`` / ``opencode attach``),
# installed via the ``opencode-ai`` npm package. No login/logout/status argv
# is wired yet — readiness is binary-only until an auth check exists.
OPENCODE_KEY = "opencode"
# Goose authenticates against its own config (``goose configure`` → keyring /
# ``~/.config/goose/config.yaml``) with no Omnigent-managed credential, and ships
# via Homebrew / a curl installer rather than npm — so it carries an
# ``install_hint``, not a ``package``.
GOOSE_KEY = "goose"
# Copilot runs in-process via the ``github-copilot-sdk`` package, which bundles
# the Copilot CLI binary it drives — so, like cursor, there is no separately
# installed CLI to gate on; readiness is whether a GitHub token resolves (see
# :func:`omnigent.onboarding.harness_readiness.harness_is_configured`). The key
# is kept here purely as the canonical harness id the readiness layer shares.
COPILOT_KEY = "copilot"
# Hermes Agent is installed via a curl installer from Nous Research and
# authenticates through its own ``hermes model`` interactive flow (no
# Omnigent-managed credentials). The ``hermes`` binary must be on PATH.
HERMES_KEY = "hermes"
_HERMES_INSTALL_HINT = "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash"
# Keyed by harness family (Claude=anthropic, Codex=openai) plus the pi
# fallback. Binaries/packages mirror ucode's ``TOOL_SPECS`` so the two tools
# install the same thing. Login/logout argv use each CLI's first-class auth
# subcommands (``claude auth login --claudeai`` / ``codex login``), so the user
# can sign in to a subscription from ``configure harnesses`` directly.
_HARNESS_INSTALL: dict[str, HarnessInstallSpec] = {
ANTHROPIC_FAMILY: HarnessInstallSpec(
"Claude",
"claude",
"@anthropic-ai/claude-code",
login_args=("auth", "login", "--claudeai"),
logout_args=("auth", "logout"),
status_args=("auth", "status"),
login_status_key="loggedIn",
# The native bridge injects Omnigent's MCP relay via `--mcp-config`;
# that flag first shipped in Claude Code 0.2.75.
min_version=_CLAUDE_MIN_VERSION,
),
OPENAI_FAMILY: HarnessInstallSpec(
"Codex",
"codex",
"@openai/codex",
login_args=("login",),
logout_args=("logout",),
status_args=("login", "status"),
# The native Codex policy hook requires ``codex >= 0.129.0``;
# anything older silently disables tool-call enforcement. Setup
# enforces the same floor up-front.
min_version=_CODEX_MIN_VERSION,
),
PI_KEY: HarnessInstallSpec(
"Pi",
"pi",
"@earendil-works/pi-coding-agent",
# The ``--approve`` / non-interactive trust override requires
# ``pi >= 0.79.0``; older CLIs would prompt mid-session.
min_version=_PI_MIN_VERSION,
),
# Pin the install to the supported 1.17.x range: opencode-ai's npm ``latest``
# is a ``0.0.0-beta-*`` pre-release, so a bare ``opencode-ai`` would install a
# version the runtime version-check (``check_opencode_version``,
# >=1.17.7,<1.18.0) then rejects. ``~1.17.7`` mirrors that exact range.
# The same version bounds are enforced in setup via ``min_version`` /
# ``max_version_exclusive`` so the install/upgrade prompt fires before
# the runtime gate does.
OPENCODE_KEY: HarnessInstallSpec(
"OpenCode",
"opencode",
"opencode-ai@~1.17.7",
min_version=OPENCODE_MIN_VERSION,
max_version_exclusive=OPENCODE_MAX_VERSION_EXCLUSIVE,
),
QWEN_KEY: HarnessInstallSpec(
"Qwen Code",
"qwen",
"@qwen-code/qwen-code",
# NB: deliberately no login/logout/status args. Qwen *removed* its
# ``auth`` subcommand and has no CLI login — ``qwen login`` doesn't
# exist and ``qwen auth status`` prints "auth has been removed" and
# exits 0 (which would make harness_cli_logged_in falsely report a
# login via its exit-code fallback). Auth is via OpenAI-compatible env
# vars or the interactive ``/auth`` command; the setup wizard handles
# that in ``_manage_qwen_harness``. Leaving these None keeps
# harness_login/logout/cli_logged_in no-ops for qwen.
min_version=_QWEN_MIN_VERSION,
),
CURSOR_KEY: HarnessInstallSpec(
"Cursor",
"cursor-agent",
package=None,
login_args=("login",),
logout_args=("logout",),
status_args=("status", "--format", "json"),
install_hint="curl https://cursor.com/install -fsS | bash",
login_status_key="isAuthenticated",
# Cursor CLI versions are calendar dates; only support builds from
# after 2026-06-01 for the native harness path.
min_version=_CURSOR_MIN_VERSION,
),
# Kimi Code CLI ships a single-binary ``kimi`` via a curl installer (no
# npm). ``kimi login`` is the interactive provider login (OAuth or a
# Moonshot API key). ``status_args`` is intentionally ``None``: kimi has
# no first-class "am I logged in?" exit-code probe — login state is
# inspected file-based via ``kimi_auth.kimi_login_detected`` instead. With
# ``None`` the login path runs every time the operator asks for it
# (interactive, so they can cancel if already authenticated).
# ``logout_args`` is ``None`` because kimi has no ``kimi logout`` subcommand
# (verified against kimi CLI v0.29.1 — ``kimi logout`` errors "unknown
# command"), so ``harness_logout`` is a no-op for it (same as Qwen / agy).
KIMI_KEY: HarnessInstallSpec(
"Kimi",
"kimi",
package=None,
login_args=("login",),
install_hint="curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash",
# First kimi-cli release after 2026-06-01. Older builds may lack
# newer TUI/session wiring needed by the native harness.
min_version=_KIMI_MIN_VERSION,
),
KIRO_KEY: HarnessInstallSpec(
"Kiro",
"kiro-cli",
package=None,
install_hint="curl -fsSL https://cli.kiro.dev/install | bash",
min_version=_KIRO_MIN_VERSION,
),
# The native Antigravity (agy) TUI bridge wraps the ``agy`` CLI. agy's auth
# service is reached by launching ``agy`` itself (no login subcommand); after
# the browser flow completes, ``agy models`` exits 0 and provides the same
# revocation-aware status probe Codex gets from ``codex login status``. An
# empty ``login_args`` tuple intentionally means “run the binary with no
# arguments” in ``harness_login``. ``agy`` ships via a shell installer rather
# than npm, so ``package`` is ``None`` and the manual command lives in
# ``install_hint`` (shown as guidance; ``install_harness_cli`` refuses to
# auto-run it).
GEMINI_FAMILY: HarnessInstallSpec(
"Antigravity",
"agy",
package=None,
login_args=(),
status_args=("models",),
install_hint="curl -fsSL https://antigravity.google/cli/install.sh | bash",
auth_hint="run `agy` and complete the browser sign-in",
),
GOOSE_KEY: HarnessInstallSpec(
"Goose",
"goose",
package=None,
install_hint="brew install block-goose-cli",
min_version=_GOOSE_MIN_VERSION,
),
HERMES_KEY: HarnessInstallSpec(
"Hermes",
"hermes",
package=None,
install_hint=_HERMES_INSTALL_HINT,
install_command=("bash", "-c", _HERMES_INSTALL_HINT),
min_version=_HERMES_MIN_VERSION,
),
}
# Maps an executor *harness identifier* (the value the runtime resolves from a
# spec's ``executor.config["harness"]`` / ``executor.type``) to its
# :data:`_HARNESS_INSTALL` family key. Only the CLI-backed harnesses appear
# here — the ones that cannot launch without a binary on ``PATH``:
# ``claude-native`` wraps the ``claude`` CLI, ``codex-native`` the ``codex``
# CLI, ``pi`` / ``pi-native`` the ``pi`` CLI, ``opencode-native`` the
# ``opencode`` CLI, ``qwen`` / ``qwen-code`` the ``qwen`` CLI,
# ``cursor-native`` / ``native-cursor`` the ``cursor-agent`` CLI, and
# ``kiro-native`` / ``native-kiro`` the ``kiro-cli`` CLI. Cursor and Kiro
# install out-of-band rather than through npm — see their ``install_hint``
# values.
# SDK-based harnesses run in-process and are deliberately absent, so they
# resolve to "no CLI required": ``claude-sdk``, ``codex``, ``openai-agents-sdk``,
# the in-process ``antigravity`` Gemini SDK harness, and the SDK ``cursor``
# harness (which drives the ``cursor-sdk`` Python package over its own bundled
# bridge, NOT the ``cursor-agent`` CLI).
_HARNESS_NAME_TO_KEY: dict[str, str] = {
"claude-native": ANTHROPIC_FAMILY,
"codex-native": OPENAI_FAMILY,
PI_KEY: PI_KEY,
"pi-native": PI_KEY,
# Kimi is multi-provider but binary-gated: cannot launch without the
# ``kimi`` CLI on PATH. Listed here so ``required_cli_for_harness``
# returns its install spec and ``missing_harness_cli`` fails loud
# before a subagent spawn.
KIMI_KEY: KIMI_KEY,
"cursor-native": CURSOR_KEY,
"native-cursor": CURSOR_KEY,
"kiro-native": KIRO_KEY,
"native-kiro": KIRO_KEY,
# The native agy TUI bridge wraps the ``agy`` CLI; both spellings map to
# the Gemini family's install spec. (The in-process ``antigravity`` SDK
# harness is deliberately absent — like the other SDK harnesses it needs no
# CLI binary.)
"antigravity-native": GEMINI_FAMILY,
"native-antigravity": GEMINI_FAMILY,
"goose-native": GOOSE_KEY,
"native-goose": GOOSE_KEY,
# Headless Goose (``harness: goose``, drives ``goose acp``) wraps the same
# ``goose`` CLI as the native TUI, so it gates on the same binary.
GOOSE_KEY: GOOSE_KEY,
# Native Kimi TUI harness — same binary gate as the bare ``kimi`` surface.
"kimi-native": KIMI_KEY,
"native-kimi": KIMI_KEY,
QWEN_KEY: QWEN_KEY,
"qwen-code": QWEN_KEY,
# Native qwen TUI (``qwen-native``) wraps the same ``qwen`` CLI as the ACP
# harness; the ``native-qwen`` reversed spelling gates on the same binary.
"qwen-native": QWEN_KEY,
"native-qwen": QWEN_KEY,
# Native OpenCode (``opencode-native``) wraps the ``opencode`` CLI; its
# ``native-opencode`` reversed spelling gates on the same binary.
"opencode-native": OPENCODE_KEY,
"native-opencode": OPENCODE_KEY,
# Hermes Agent (``harness: hermes``) wraps the ``hermes`` CLI.
HERMES_KEY: HERMES_KEY,
# Native Hermes TUI (``hermes-native``, via ``omni hermes``) wraps the same
# ``hermes`` CLI as the headless harness; ``native-hermes`` reversed spelling
# gates on the same binary.
"hermes-native": HERMES_KEY,
"native-hermes": HERMES_KEY,
}
# UI-installable harnesses: the identifiers the web UI's New Chat dialog may
# request an install for, mapped to their :data:`_HARNESS_INSTALL` key. Single
# source of truth for both the host install handler (which runs the installer)
# and the server route (which allowlists the request). Scope is deliberately
# narrow — npm-installable, key/env-auth harnesses only; curl/brew/shell
# installers (cursor, kimi, hermes, …) are absent, so an install request for
# them is rejected before any installer runs.
_UI_INSTALLABLE_HARNESS_TO_KEY: dict[str, str] = {
"claude": ANTHROPIC_FAMILY,
"codex": OPENAI_FAMILY,
PI_KEY: PI_KEY,
OPENCODE_KEY: OPENCODE_KEY,
QWEN_KEY: QWEN_KEY,
}
# Builtin ACP CLI harnesses (omnigent/acp_cli_harnesses.py) with an npm package
# are one-click installable; rows shipping via curl/shell installers stay out,
# like cursor/kimi above.
for _acp_name, _acp_row in ACP_CLI_HARNESSES.items():
if _acp_row.install.package is not None:
for _acp_spelling in (_acp_name, *_acp_row.aliases):
_UI_INSTALLABLE_HARNESS_TO_KEY[_acp_spelling] = _acp_name
# Family keys the UI may install, derived once from the allowlist so the
# executor-spelling fallback in ``ui_install_key`` can't admit a non-installable
# family (e.g. cursor) that happens to share the name map.
_UI_INSTALLABLE_KEYS: frozenset[str] = frozenset(_UI_INSTALLABLE_HARNESS_TO_KEY.values())
def ui_install_key(harness: str) -> str | None:
"""Resolve a harness identifier to its UI-installable install-spec key.
Accepts both the bare install ids (``"claude"``, ``"codex"``, ``"pi"``,
``"opencode"``, ``"qwen"``) and the executor spellings a session actually
carries — the native TUI wrappers (``"codex-native"``, ``"qwen-native"``,
…) resolve through the shared :data:`_HARNESS_NAME_TO_KEY` map to the same
family key. Any harness that doesn't map onto the UI-installable family set
(SDK harnesses like ``"claude-sdk"``, or curl/OAuth harnesses like
``"cursor"``/``"hermes"``) returns ``None`` so the caller rejects it.
:param harness: A harness identifier from the web UI, e.g. ``"claude"`` or
``"codex-native"``.
:returns: The :data:`_HARNESS_INSTALL` key (e.g. ``"anthropic"``) when the
harness is UI-installable; ``None`` otherwise (caller rejects it).
"""
direct = _UI_INSTALLABLE_HARNESS_TO_KEY.get(harness)
if direct is not None:
return direct
# Fall back to the executor-spelling map, but only accept keys that are
# themselves UI-installable — this keeps curl/OAuth harnesses (cursor,
# hermes, …) out even though they appear in _HARNESS_NAME_TO_KEY.
key = _all_harness_name_to_key().get(harness)
if key is not None and key in _UI_INSTALLABLE_KEYS:
return key
return None
def ui_installable_harnesses() -> frozenset[str]:
"""Return every harness identifier the web UI may install.
Includes the bare install ids and all executor spellings that resolve to a
UI-installable family (e.g. ``"codex-native"``, ``"qwen-native"``), so the
New Chat dialog can offer setup for the harness a session actually declares
— not just the bare ids.
:returns: The full set of accepted harness identifiers, e.g.
``{"claude", "claude-native", "codex", "codex-native", "pi", ...}``.
"""
resolvable = set(_UI_INSTALLABLE_HARNESS_TO_KEY)
for name, mapped in _all_harness_name_to_key().items():
if mapped in _UI_INSTALLABLE_KEYS:
resolvable.add(name)
return frozenset(resolvable)
# The families whose credential the UI can WRITE (Claude/Codex/Pi). A strict
# subset of the installable families: opencode/qwen are installable but env-auth
# (omnigent stores no key for them), so they are NOT credential-configurable.
# ``pi`` consumes anthropic/openai and is handled by the host store-secret
# handler, so it's included via its own key.
_UI_CREDENTIAL_FAMILIES: frozenset[str] = frozenset({ANTHROPIC_FAMILY, OPENAI_FAMILY, PI_KEY})
def ui_credential_configurable_harnesses() -> frozenset[str]:
"""Return every harness identifier the web UI may write a credential for.
A strict subset of :func:`ui_installable_harnesses`: only harnesses whose
provider credential omnigent owns (Claude / Codex / Pi families). The
env-auth harnesses (opencode, qwen) are installable but excluded — the host
store-secret handler can't configure them — so the credential route can
reject them with a clean 400 rather than forwarding a frame the host fails.
:returns: The set of harness identifiers accepted by the credential route,
e.g. ``{"claude", "claude-native", "codex", "codex-native", "pi", ...}``.
"""
resolvable = {
h for h, key in _UI_INSTALLABLE_HARNESS_TO_KEY.items() if key in _UI_CREDENTIAL_FAMILIES
}
for name, mapped in _all_harness_name_to_key().items():
if mapped in _UI_CREDENTIAL_FAMILIES:
resolvable.add(name)
return frozenset(resolvable)
# The auth step per UI-installable family, for the setup checklist. These are
# display-only checklist rows (the command is shown for the user to run on the
# host, never executed server-side), so the commands are literal here rather
# than derived from ``HarnessInstallSpec.login_args`` — keep them in sync with
# that spec by hand if a harness's login command changes.
# ``command`` steps run on the host and are status-tracked; ``setup`` steps
# ``command`` steps run on the host and are status-tracked; ``auth`` steps
# (pi) open the UI credential form and are status-tracked; ``setup`` steps
# (qwen: env-auth, not UI-authable) point at ``omni setup`` and don't track.
# claude/codex: subscription login via the CLI's own login command.
# opencode: its own `opencode auth login`.
# pi: a provider credential (API key / gateway / adopt) written from the UI.
# qwen: env-auth — omnigent stores no key, so it stays a setup signpost.
_UI_AUTH_STEP_BY_KEY: dict[str, SetupStep] = {
ANTHROPIC_FAMILY: SetupStep(
kind="auth",
# UI-authable: the dialog opens a form listing every way to authenticate
# (subscription login, API key, gateway, or adopting a detected key), so
# the row is a neutral "set up auth" rather than naming just one path —
# the subscription ``command`` below is one option inside that form, not
# the whole step.
title="Set up authentication",
detail="Sign in with your Claude subscription, an API key, or a gateway.",
action="auth",
command="claude auth login --claudeai",
status_key="authed",
),
OPENAI_FAMILY: SetupStep(
kind="auth",
title="Set up authentication",
detail="Sign in with your ChatGPT subscription, an API key, or a gateway.",
action="auth",
command="codex login",
status_key="authed",
),
OPENCODE_KEY: SetupStep(
kind="auth",
title="Sign in to OpenCode",
detail="OpenCode manages its own credentials — sign in on the host.",
action="command",
command="opencode auth login",
status_key="authed",
),
PI_KEY: SetupStep(
kind="auth",
# Same neutral "set up auth" framing as claude/codex — the dialog opens a
# form listing the ways to authenticate. Pi has NO subscription CLI login
# (``command=None``, so the form omits that option), so the detail names
# only the applicable paths. ``status_key="authed"`` keeps the step
# trackable so it isn't dropped as "unknown" (which would wrongly read
# "ready").
title="Set up authentication",
detail="Add an API key or a gateway so Pi can run.",
action="auth",
command=None,
status_key="authed",
),
QWEN_KEY: SetupStep(
kind="auth",
title="Add a Qwen credential",
# Qwen is env-auth (not UI-authable): omnigent stores no key for it, so
# this stays an untrackable signpost pointing at the CLI.
detail="Qwen needs an API key or gateway. Set it up on the host for now.",
action="setup",
command="omni setup",
status_key=None,
),
}
# Builtin ACP CLI harnesses own their credentials (vendor CLI login), so each
# row with a login command gets a run-on-host auth step, untracked like qwen's.
for _acp_name, _acp_row in ACP_CLI_HARNESSES.items():
_acp_login = _acp_row.login_command
if _acp_login is not None:
_UI_AUTH_STEP_BY_KEY[_acp_name] = SetupStep(
kind="auth",
title=f"Sign in to {_acp_row.label}",
detail=f"{_acp_row.label} manages its own credentials; sign in on the host.",
action="command",
command=_acp_login,
status_key=None,
)
def ui_setup_steps(harness: str) -> list[SetupStep]:
"""Return the ordered setup checklist for a UI harness identifier.
Mirrors what ``omni setup`` walks a user through for the harness: an
install step, then (for the five first-class families) an auth step. The
install step's label uses the harness's :class:`HarnessInstallSpec` display
name; the auth step's command is a display-only literal from
:data:`_UI_AUTH_STEP_BY_KEY` (shown for the user to run, not executed).
Harnesses outside the UI-installable set get a single generic
"run ``omni setup``" step (M1 scope).
:param harness: A harness identifier the UI holds, e.g. ``"codex"`` or the
native spelling ``"codex-native"`` (both resolve to the same steps).
:returns: Ordered :class:`SetupStep` list; never empty.
"""
key = ui_install_key(harness)
if key is None:
# Not UI-installable (curl/OAuth/SDK harness): one generic step.
return [
SetupStep(
kind="install",
title="Set up on the host",
detail="Run omni setup on the host to configure this agent.",
action="setup",
command="omni setup",
status_key=None,
)
]
spec = _all_harness_install().get(key)
display = spec.display if spec is not None else harness
steps = [
SetupStep(
kind="install",
title=f"Install {display}",
detail=f"We'll install {display} on the host for you.",
action="install",
command=None,
status_key="installed",
)
]
auth = _UI_AUTH_STEP_BY_KEY.get(key)
if auth is not None:
steps.append(auth)
return steps
def _all_harness_install() -> dict[str, HarnessInstallSpec]:
from omnigent.harness_plugins import install_specs
merged = dict(_HARNESS_INSTALL)
merged.update(install_specs())
return merged
def _all_harness_name_to_key() -> dict[str, str]:
from omnigent.harness_plugins import harness_install_keys
merged = dict(_HARNESS_NAME_TO_KEY)
merged.update(harness_install_keys())
return merged
def required_cli_for_harness(harness: str) -> HarnessInstallSpec | None:
"""Return the CLI a harness needs on ``PATH`` to launch, or ``None``.
:param harness: An executor harness identifier, e.g. ``"pi"``,
``"claude-native"``, ``"codex-native"``, or an SDK harness like
``"claude-sdk"``.
:returns: The :class:`HarnessInstallSpec` whose ``binary`` must be on
``PATH`` for *harness* to start; ``None`` for SDK-based / unknown
harnesses that need no CLI binary.
"""
key = _all_harness_name_to_key().get(harness)
return _all_harness_install().get(key) if key is not None else None
def missing_harness_cli(harness: str) -> HarnessInstallSpec | None:
"""Return a harness's required CLI spec when that CLI can't be used.
Combines :func:`required_cli_for_harness` with the same probe
:func:`harness_cli_installed` uses, so the verdict matches what the
harness's own launch will see (both check ``PATH`` plus the common global
install dirs the host daemon's frozen ``PATH`` may omit, and now also the
declared version range). Used by sub-agent dispatch to fail loud *before*
spawning a worker whose harness can never boot here, instead of letting
the missing or incompatible binary surface as a lazy, generic turn failure.
:param harness: An executor harness identifier, e.g. ``"pi"`` or
``"claude-native"``.
:returns: The :class:`HarnessInstallSpec` for a CLI-backed harness whose
``binary`` is not on ``PATH`` or is outside its supported version range;
``None`` when the harness needs no CLI (SDK-based / unknown) or the
required binary is present and version-compatible.
"""
spec = required_cli_for_harness(harness)
if spec is None:
return None
install_key = _all_harness_name_to_key().get(harness)
if install_key is not None and harness_cli_installed(install_key):
return None
return spec
def harness_setup_hint(harness: str | None) -> str:
"""Return actionable remediation when *harness* can't launch on a machine.
Most CLI harnesses (``claude``/``codex``/``pi``) install via npm and a
model credential, both of which ``omni setup`` handles — so they route
there. But a harness whose CLI ships out-of-band (``cursor-agent``, via
Cursor's own curl installer rather than npm — it carries an ``install_hint``
and no ``package``) is **not** installed by ``omni setup``: pointing a
native-Cursor user there is a dead end, since setup only configures the
SDK-based ``cursor`` harness (``cursor-sdk`` + ``CURSOR_API_KEY``). For
those, name the vendor installer and the CLI's own login instead.
:param harness: An executor harness identifier, e.g. ``"cursor-native"``,
``"claude-native"``, or ``"codex"``; ``None`` falls back to the
``omni setup`` hint.
:returns: A remediation clause for the "harness not configured" message,
e.g. ``"install the cursor-agent CLI on that machine with `curl
https://cursor.com/install -fsS | bash`, then run `cursor-agent
login`"`` for native Cursor, or the ``omni setup`` hint otherwise.
"""
spec = required_cli_for_harness(harness or "")
if spec is not None and spec.package is None and spec.install_hint:
login = ""
if spec.login_args:
login = f", then run `{spec.binary} {' '.join(spec.login_args)}`"
elif spec.auth_hint:
login = f", then {spec.auth_hint}"
return f"install the {spec.binary} CLI on that machine with `{spec.install_hint}`{login}"
return "run `omni setup` on that machine to install the CLI and set a default credential"
_VERSION_RE = re.compile(r"(\d+\.\d+\.\d+(?:[-.][0-9A-Za-z]+)*)")
def _normalize_date_version(version: str) -> str:
"""Trim date-shaped versions (``YYYY.MM.DD[-build]``) to their date part.
Cursor's CLI reports versions like ``2026.07.01-777f564`` or
``2026.06.19-20-24-33-653a7fb`` on Windows. ``packaging`` rejects those
as PEP 440, but the leading ``YYYY.MM.DD`` is enough to compare chronology.
Normalizing keeps semver versions intact so existing parsing is unaffected.
"""
parts = re.split(r"[.-]", version.replace("_", "-"))
if len(parts) < 3:
return version
try:
year = int(parts[0])
month = int(parts[1])
day = int(parts[2])
except ValueError:
return version
# Treat plausible calendar dates (year 2000-2199) as date versions.
if 2000 <= year <= 2199 and 1 <= month <= 12 and 1 <= day <= 31:
return f"{year}.{month:02d}.{day:02d}"
return version
def _parse_harness_cli_version(text: str) -> str | None:
"""Extract a semver-ish string from ``<binary> --version`` output.
Mirrors the OpenCode-specific parser in
:func:`omnigent.opencode_native_app_server.parse_opencode_version` but is
kept generic so any harness can declare a version range in its install spec.
Date-shaped versions (e.g. Cursor's ``2026.06.22`` or
``2026.06.19-20-24-33-653a7fb``) are normalized to ``YYYY.MM.DD``.
"""
match = _VERSION_RE.search(text or "")
if match is None:
return None
return _normalize_date_version(match.group(1))
# Wall-clock cap on a harness CLI probe subprocess (``--version`` /
# ``auth status``). The default stays lenient for setup and launch gating; the
# throttled readiness refresh passes ``READINESS_CLI_PROBE_TIMEOUT_S`` so a hung
# CLI can't stall the refresh — and, through it, the host tunnel's keepalive.
# The readiness cap matches goose's status-probe budget (``_INFO_TIMEOUT_S``):
# enough for a healthy ``auth status`` keychain read / token refresh, short
# enough that a wedged CLI fails fast.
_DEFAULT_CLI_PROBE_TIMEOUT_S = 30.0
READINESS_CLI_PROBE_TIMEOUT_S = 10.0
def _harness_cli_version_satisfies(
spec: HarnessInstallSpec,
binary: str,
timeout: float = _DEFAULT_CLI_PROBE_TIMEOUT_S,
) -> bool:
"""Check *binary*'s ``--version`` against *spec*'s declared range.
A missing/unparseable version or a subprocess error is treated as not
satisfying the range, so an installed but incompatible CLI is reported
as not ready and the setup flow prompts for an upgrade before the
runtime gate rejects it.
:param timeout: Seconds to wait for the ``--version`` subprocess before
giving up, e.g. ``10.0`` on the readiness path.
"""
if spec.min_version is None and spec.max_version_exclusive is None:
return True
version = _harness_cli_version_string(spec, binary, timeout)
if version is None:
return False
try:
parsed = Version(version)
except InvalidVersion:
return False
if spec.min_version is not None:
try:
if parsed < Version(spec.min_version):
return False
except InvalidVersion:
return False
if spec.max_version_exclusive is not None:
try:
if parsed >= Version(spec.max_version_exclusive):
return False
except InvalidVersion:
return False
return True
def harness_install_spec(key: str) -> HarnessInstallSpec | None:
"""Return the install spec for a family/harness key, or ``None``.
:param key: A harness family (``"anthropic"`` / ``"openai"``) or
:data:`PI_KEY` (``"pi"``).
:returns: The :class:`HarnessInstallSpec`, or ``None`` for an unknown key
(e.g. a gateway-only family with no dedicated CLI).
"""
return _all_harness_install().get(key)
def harness_cli_version_satisfies(key: str) -> bool:
"""Return whether the installed CLI for *key* satisfies its version range.
Only harnesses that declare ``min_version`` / ``max_version_exclusive`` in
their install spec are probed. A missing binary, an unparsable version, or
a subprocess error is treated as not satisfying the range — the setup flow
will then prompt for an upgrade before the runtime gate rejects it.
:param key: A harness family key, e.g. :data:`OPENCODE_KEY`.
:returns: ``True`` when the binary is present and its version falls inside
the declared range, or when the spec has no version bounds.
"""
spec = harness_install_spec(key)
if spec is None:
return False
binary = resolve_cli_binary(spec.binary)
if binary is None:
return False
return _harness_cli_version_satisfies(spec, binary)
def harness_cli_installed(key: str, timeout: float = _DEFAULT_CLI_PROBE_TIMEOUT_S) -> bool:
"""Return whether the harness's CLI is present and meets its version range.
"Installed" now means the CLI binary (:func:`resolve_cli_binary`) is
resolvable **and**, when the harness declares ``min_version`` /
``max_version_exclusive`` in its install spec, the binary's
``--version`` output satisfies that range. This prevents an outdated
native CLI (e.g. an OpenCode release outside the supported 1.17.x band)
from being treated as ready during setup.
:param key: A harness family (``"anthropic"`` / ``"openai"``) or
:data:`PI_KEY` / :data:`KIMI_KEY`.
:param timeout: Seconds to wait for the ``--version`` probe subprocess,
e.g. ``10.0`` on the readiness path where a hung CLI must not stall
the refresh.
:returns: ``True`` when the CLI resolves and is version-compatible;
``False`` when it doesn't resolve, the key has no associated CLI,
or its version falls outside the declared range.
"""
spec = harness_install_spec(key)
if spec is None:
return False
binary = resolve_cli_binary(spec.binary)
if binary is None:
return False
return _harness_cli_version_satisfies(spec, binary, timeout)
def harness_cli_version(key: str) -> tuple[str | None, str | None]:
"""Return the installed CLI's version string plus the declared range.
Useful for human-readable status messages when the CLI is present but
outside its supported range, so the UI can say "installed vX, required >=Y"
instead of just "not installed".
:param key: A harness family key, e.g. :data:`OPENCODE_KEY`.
:returns: ``(version, range_str)``. ``version`` is ``None`` when the binary
is missing or its ``--version`` output is unparseable. ``range_str``
is a human-readable summary of the declared ``min_version`` /
``max_version_exclusive`` range, or ``None`` when the spec has no
version bounds.
"""
spec = harness_install_spec(key)
if spec is None:
return None, None
binary = resolve_cli_binary(spec.binary)
if binary is None:
return None, None
version = _harness_cli_version_string(spec, binary)
if version is None:
return None, _version_range_str(spec)
return version, _version_range_str(spec)
def _version_range_str(spec: HarnessInstallSpec) -> str | None:
"""Human-readable rendering of a spec's version range, or ``None``."""
if spec.min_version is None and spec.max_version_exclusive is None:
return None
if spec.min_version is not None and spec.max_version_exclusive is None:
return f">={spec.min_version}"
if spec.min_version is None and spec.max_version_exclusive is not None:
return f"<{spec.max_version_exclusive}"
return f">={spec.min_version}, <{spec.max_version_exclusive}"
def _harness_cli_version_string(
spec: HarnessInstallSpec,
binary: str,
timeout: float = _DEFAULT_CLI_PROBE_TIMEOUT_S,
) -> str | None:
"""Return the parsed, normalized version string from *binary* ``--version``.
:param timeout: Seconds to wait for the ``--version`` subprocess, e.g.
``6.0`` on the readiness path.
"""
try:
completed = subprocess.run(
[binary, "--version"],
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
except (OSError, subprocess.SubprocessError):
return None
return _parse_harness_cli_version((completed.stdout or "") + "\n" + (completed.stderr or ""))
def harness_install_command(key: str) -> list[str]:
"""Return the argv that installs the harness CLI.
:param key: A harness family or :data:`PI_KEY`.
:returns: The install command, e.g. ``["npm", "install", "-g",
"@anthropic-ai/claude-code"]`` or an explicitly configured vendor
installer command.
:raises KeyError: If *key* has no install spec (caller should gate on
:func:`harness_install_spec`).
:raises ValueError: If *key* has a spec but no npm ``package`` (a CLI
installed out-of-band, e.g. cursor-agent); show its ``install_hint``.
"""
spec = harness_install_spec(key)
if spec is None:
raise KeyError(key)
if spec.install_command is not None:
return list(spec.install_command)
package = spec.package
if package is None:
raise ValueError(f"{key!r} has no npm package; show its install_hint instead")
return ["npm", "install", "-g", package]
class HarnessInstallResult(NamedTuple):
"""Outcome of :func:`try_install_harness_cli`.
:param installed: Whether the CLI resolves after the attempt, via the same
:func:`resolve_cli_binary` ladder readiness uses (``PATH`` plus the
common global install dirs), not bare ``PATH`` alone.
:param reason: Human-readable failure reason when ``installed`` is False;
``None`` on success.
"""
installed: bool
reason: str | None
def try_install_harness_cli(key: str) -> HarnessInstallResult:
"""Install the harness CLI, returning whether it landed and why not.
Same behavior and side effects as :func:`install_harness_cli` (the
installer's output streams to this process, uncaptured, so failures stay
visible in the setup terminal / host log), but returns a human-readable
reason so a UI-driven install can surface "npm is not available on the
host" instead of a silent boolean failure.
:param key: A harness family or :data:`PI_KEY`.
:returns: A :class:`HarnessInstallResult` — ``(True, None)`` once the CLI
resolves via :func:`resolve_cli_binary` (including the no-op where it
was already present), otherwise ``(False, reason)`` naming the failure
(manual-only spec, missing installer, timeout, OS error, non-zero exit,
or a post-install binary-not-found).
:raises KeyError: If *key* has no install spec.
"""
spec = harness_install_spec(key)
if spec is not None and spec.package is None and spec.install_command is None:
# Manual-only CLI (e.g. cursor-agent): caller shows install_hint.
return HarnessInstallResult(False, f"{spec.binary!r} is not installable automatically")
cmd = harness_install_command(key)
if shutil.which(cmd[0]) is None:
return HarnessInstallResult(False, f"{cmd[0]!r} is not available on the host")
try:
result = subprocess.run(cmd, check=False, timeout=300)
except subprocess.TimeoutExpired:
return HarnessInstallResult(False, "install timed out after 300s")
except OSError as exc:
return HarnessInstallResult(False, f"install command failed to run: {exc}")
# harness_install_command would have raised for a spec-less key, so spec is
# non-None past this point.
assert spec is not None
# Resolve the freshly-installed binary via the SAME ladder readiness uses
# (:func:`resolve_cli_binary` — ``PATH`` plus the nvm/npm-global/homebrew
# fallback dirs), so the install verdict and the readiness badge can never
# disagree. A bare ``shutil.which`` here would report "not found" for a
# binary the host daemon's frozen ``PATH`` omits but readiness still resolves
# via the ladder — the spurious "failed" toast next to a green "ready" tick.
resolved = resolve_cli_binary(spec.binary)
if resolved is not None:
# Put the resolving dir on ``PATH`` for this process so the setup
# wizard's *later* steps — harness_login / harness_cli_logged_in /
# harness_logout — which shell out with the bare binary name and only
# bare ``shutil.which``, can find it too. Without this, an install that
# succeeded via a fallback dir (nvm/homebrew/…) would be followed by a
# login step that can't locate the very binary just installed.
resolved_dir = str(Path(resolved).resolve().parent)
path_entries = os.environ.get("PATH", "").split(os.pathsep)
if resolved_dir not in path_entries:
os.environ["PATH"] = os.pathsep.join([resolved_dir, *path_entries])
return HarnessInstallResult(True, None)
if result.returncode != 0:
return HarnessInstallResult(False, f"installer exited with code {result.returncode}")
return HarnessInstallResult(
False, f"installer completed but {spec.binary!r} could not be found"
)
def install_harness_cli(key: str) -> bool:
"""Install the harness CLI; return whether it landed on ``PATH``.
Thin wrapper over :func:`try_install_harness_cli` that discards the failure
reason, preserving the boolean contract the setup wizard relies on.
:param key: A harness family or :data:`PI_KEY`.
:returns: ``True`` when the CLI is on ``PATH`` after the install attempt,
``False`` if the installer is missing or the install failed.
:raises KeyError: If *key* has no install spec.
"""
return try_install_harness_cli(key).installed
def harness_cli_logged_in(key: str, timeout: float = _DEFAULT_CLI_PROBE_TIMEOUT_S) -> bool:
"""Return whether the harness CLI itself reports a usable login.
Asks the CLI's own status command (``claude auth status`` /
``codex login status`` / ``agy models``) instead of reading a credential
file, because the file location is platform-specific — Claude Code stores
its tokens in the macOS Keychain rather than ``~/.claude/.credentials.json``
on macOS, so a file check would falsely report "not logged in" right after a
successful ``claude auth login``. The status command reads wherever the CLI
actually stored the credential, so this is correct on every platform.
Two output shapes are handled, selected explicitly by the spec's
``login_status_key``: a CLI that publishes a JSON status object names its
boolean field there (Claude ``loggedIn`` / Cursor ``isAuthenticated``) and
is read structurally; a CLI with no ``login_status_key`` has no JSON verdict
(Codex's human line, ``agy models``' model list), so the exit code decides
(``0`` only when logged in) and stdout is never parsed.
:param key: A harness family, e.g. ``"anthropic"`` (Claude),
``"openai"`` (Codex), or ``"gemini"`` (Antigravity, via ``agy models``).
:param timeout: Seconds to wait for the status subprocess, e.g. ``10.0`` on
the readiness path where a hung CLI must not stall the refresh.
:returns: ``True`` when the CLI reports a usable login; ``False`` when the
key has no status command, the CLI binary is missing, the status
process failed to spawn, or the CLI reports no login.
"""
spec = harness_install_spec(key)
if spec is None or spec.status_args is None:
return False
binary = resolve_cli_binary(spec.binary) if key == GEMINI_FAMILY else shutil.which(spec.binary)
if binary is None:
return False
argv_binary = binary if key == GEMINI_FAMILY else spec.binary
try:
result = subprocess.run(
[argv_binary, *spec.status_args],
check=False,
timeout=timeout,
capture_output=True,
text=True,
)
except (OSError, subprocess.TimeoutExpired):
return False
# Dispatch is explicit per spec: a harness that publishes a JSON status
# object names its boolean field in ``login_status_key`` (Claude
# ``loggedIn`` / Cursor ``isAuthenticated``). When that key is unset the
# harness has no JSON verdict (Codex's human line, agy's model list), so the
# exit code is authoritative and stdout is never parsed — output that merely
# happens to be JSON can't flip the verdict.
status_key = spec.login_status_key
if status_key is not None:
try:
payload = json.loads(result.stdout)
except (json.JSONDecodeError, ValueError):
return result.returncode == 0
if isinstance(payload, dict) and status_key in payload:
return bool(payload[status_key])
return result.returncode == 0
def harness_login(key: str) -> bool:
"""Run the harness CLI's interactive subscription login; return logged-in state.
Lets ``configure harnesses`` be the single place to sign in: when the user
picks a subscription or Antigravity sign-in, we drive the harness's own
auth entrypoint (``claude auth login --claudeai`` / ``codex login`` / bare
``agy``) **in the foreground** (inheriting stdio so OAuth / device-code
prompts and browser URLs reach the user), then confirm via
:func:`harness_cli_logged_in`.
If the CLI is already logged in this is a no-op that returns ``True``
immediately (no redundant re-auth).
:param key: A harness family, ``"anthropic"`` (Claude), ``"openai"``
(Codex), or ``"gemini"`` (Antigravity).
:returns: ``True`` when the harness CLI is logged in after the attempt
(including the already-logged-in short-circuit); ``False`` when the key
has no login command, the CLI binary is missing, the login process
failed to spawn, or the user did not complete the login.
"""
spec = harness_install_spec(key)
if spec is None or spec.login_args is None:
return False
binary = resolve_cli_binary(spec.binary) if key == GEMINI_FAMILY else shutil.which(spec.binary)
if binary is None:
return False
argv_binary = binary if key == GEMINI_FAMILY else spec.binary
if harness_cli_logged_in(key):
return True
try:
# Open /dev/tty explicitly so the child process sees a real TTY even
# when the parent's stdio is piped (e.g. launched via `uv tool run` or
# another wrapper). The Claude CLI checks isatty() and skips opening the
# browser when it returns false, which strands the login until it times
# out. Fall back to inherited stdio when /dev/tty can't be opened (a
# headless run with no controlling terminal).
tty_fd: int | None = None
if not sys.stdin.isatty():
try:
tty_fd = os.open("/dev/tty", os.O_RDWR)
except OSError:
tty_fd = None
argv = [argv_binary, *spec.login_args]
try:
if tty_fd is not None:
subprocess.run(
argv, check=False, timeout=600, stdin=tty_fd, stdout=tty_fd, stderr=tty_fd
)
else:
subprocess.run(argv, check=False, timeout=600)
finally:
if tty_fd is not None:
os.close(tty_fd)
except (OSError, subprocess.TimeoutExpired):
return False
return harness_cli_logged_in(key)
def harness_logout(key: str) -> bool:
"""Run the harness CLI's logout; return whether it is now logged out.
Drives the harness's own logout command (``claude auth logout`` /
``codex logout``) so removing a subscription from ``configure harnesses``
actually signs the user out of the standalone CLI — otherwise the
credential persists and ambient detection re-adopts the subscription on the
next ``configure`` open.
:param key: A harness family, ``"anthropic"`` (Claude) or ``"openai"``
(Codex).
:returns: ``True`` when the harness CLI is logged out after the attempt;
``False`` when the key has no logout command, the binary is missing, the
process failed to spawn, or a login still resolves afterward.
"""
spec = harness_install_spec(key)
if spec is None or spec.logout_args is None:
return False
if shutil.which(spec.binary) is None:
return False
try:
subprocess.run([spec.binary, *spec.logout_args], check=False, timeout=60)
except (OSError, subprocess.TimeoutExpired):
return False
return not harness_cli_logged_in(key)