562 lines
25 KiB
Python
562 lines
25 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.
|
|
|
|
``omnigent 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 shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
from omnigent.harness_install_spec import HarnessInstallSpec
|
|
from omnigent.onboarding.provider_config import ANTHROPIC_FAMILY, GEMINI_FAMILY, OPENAI_FAMILY
|
|
|
|
# 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 ``omnigent setup``.
|
|
KIRO_KEY = "kiro"
|
|
|
|
# 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"
|
|
|
|
|
|
# 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",
|
|
),
|
|
OPENAI_FAMILY: HarnessInstallSpec(
|
|
"Codex",
|
|
"codex",
|
|
"@openai/codex",
|
|
login_args=("login",),
|
|
logout_args=("logout",),
|
|
status_args=("login", "status"),
|
|
),
|
|
PI_KEY: HarnessInstallSpec("Pi", "pi", "@earendil-works/pi-coding-agent"),
|
|
# 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.
|
|
OPENCODE_KEY: HarnessInstallSpec("OpenCode", "opencode", "opencode-ai@~1.17.7"),
|
|
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.
|
|
),
|
|
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",
|
|
),
|
|
# 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
|
|
# only inspected interactively. With ``None`` the login path runs every
|
|
# time the operator asks for it (interactive, so they can cancel if
|
|
# already authenticated).
|
|
KIMI_KEY: HarnessInstallSpec(
|
|
"Kimi",
|
|
"kimi",
|
|
package=None,
|
|
login_args=("login",),
|
|
logout_args=("logout",),
|
|
install_hint="curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash",
|
|
),
|
|
KIRO_KEY: HarnessInstallSpec(
|
|
"Kiro",
|
|
"kiro-cli",
|
|
package=None,
|
|
install_hint="curl -fsSL https://cli.kiro.dev/install | bash",
|
|
),
|
|
# The native Antigravity (agy) TUI bridge wraps the ``agy`` CLI. ``agy`` has
|
|
# no ``login`` / ``logout`` subcommand — the user authenticates via browser
|
|
# OAuth by launching ``agy`` with no arguments on first run — so login_args /
|
|
# logout_args stay ``None`` (``harness_login`` / ``harness_logout`` no-op for
|
|
# it). It DOES expose a usable status check: ``agy models`` lists models and
|
|
# exits 0 only when signed in (else exits non-zero with "Please sign in …"),
|
|
# so status_args wires it the way Codex's exit-code ``login status`` is — so
|
|
# ``harness_cli_logged_in`` reads a real, revocation-aware verdict from the
|
|
# CLI instead of guessing from a credential file. (The readiness layer still
|
|
# uses the subprocess-free file check ``gemini_login_detected`` for its fast
|
|
# path.) ``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,
|
|
status_args=("models",),
|
|
install_hint="curl -fsSL https://antigravity.google/cli/install.sh | bash",
|
|
auth_hint="run `agy` once and complete the browser sign-in",
|
|
),
|
|
GOOSE_KEY: HarnessInstallSpec(
|
|
"Goose",
|
|
"goose",
|
|
package=None,
|
|
install_hint="brew install block-goose-cli",
|
|
),
|
|
HERMES_KEY: HarnessInstallSpec(
|
|
"Hermes",
|
|
"hermes",
|
|
package=None,
|
|
install_hint="curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash",
|
|
),
|
|
}
|
|
|
|
|
|
# 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,
|
|
}
|
|
|
|
|
|
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 is absent from ``PATH``.
|
|
|
|
Combines :func:`required_cli_for_harness` with the same
|
|
``shutil.which`` probe :func:`harness_cli_installed` uses, so the
|
|
verdict matches what the harness's own launch will see (both read the
|
|
process ``PATH``). Used by sub-agent dispatch to fail loud *before*
|
|
spawning a worker whose harness can never boot here, instead of letting
|
|
the missing 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``; ``None`` when the harness needs no CLI
|
|
(SDK-based / unknown) or the required binary is present.
|
|
"""
|
|
spec = required_cli_for_harness(harness)
|
|
if spec is None:
|
|
return None
|
|
if shutil.which(spec.binary) is not None:
|
|
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 ``omnigent 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 ``omnigent 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
|
|
``omnigent 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 ``omnigent 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 `omnigent setup` on that machine to install the CLI and set a default credential"
|
|
|
|
|
|
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_installed(key: str) -> bool:
|
|
"""Return whether the harness's CLI binary is on ``PATH``.
|
|
|
|
"Installed" is deliberately the CLI binary (``shutil.which``), matching
|
|
ucode and the npm install-prompt UX — even though the SDK-based
|
|
``claude-sdk`` harness can run without the ``claude`` CLI.
|
|
|
|
:param key: A harness family (``"anthropic"`` / ``"openai"``) or
|
|
:data:`PI_KEY` / :data:`KIMI_KEY`.
|
|
:returns: ``True`` when the CLI is on ``PATH``; ``False`` when it isn't or
|
|
the key has no associated CLI.
|
|
"""
|
|
spec = harness_install_spec(key)
|
|
if spec is None:
|
|
return False
|
|
return shutil.which(spec.binary) is not None
|
|
|
|
|
|
def harness_install_command(key: str) -> list[str]:
|
|
"""Return the argv that installs the harness CLI, e.g. ``npm install -g …``.
|
|
|
|
:param key: A harness family or :data:`PI_KEY`.
|
|
:returns: The install command, e.g.
|
|
``["npm", "install", "-g", "@anthropic-ai/claude-code"]``.
|
|
: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)
|
|
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]
|
|
|
|
|
|
def install_harness_cli(key: str) -> bool:
|
|
"""Install the harness CLI via npm; return whether it landed on ``PATH``.
|
|
|
|
Shells out to :func:`harness_install_command` and re-checks
|
|
:func:`harness_cli_installed`. Surfaces npm's own output (no capture) so a
|
|
failing install is visible. Requires ``npm`` on ``PATH``.
|
|
|
|
:param key: A harness family or :data:`PI_KEY`.
|
|
:returns: ``True`` when the CLI is on ``PATH`` after the install attempt
|
|
(including the no-op case where npm reports success but the binary is
|
|
present), ``False`` if npm is missing or the install failed.
|
|
:raises KeyError: If *key* has no install spec.
|
|
"""
|
|
spec = harness_install_spec(key)
|
|
if spec is not None and spec.package is None:
|
|
# Non-npm CLI (e.g. cursor-agent): no auto-install; caller shows install_hint.
|
|
return False
|
|
if shutil.which("npm") is None:
|
|
return False
|
|
cmd = harness_install_command(key)
|
|
try:
|
|
subprocess.run(cmd, check=False, timeout=300)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return False
|
|
return harness_cli_installed(key)
|
|
|
|
|
|
def harness_cli_logged_in(key: str) -> 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``).
|
|
: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
|
|
if shutil.which(spec.binary) is None:
|
|
return False
|
|
try:
|
|
result = subprocess.run(
|
|
[spec.binary, *spec.status_args],
|
|
check=False,
|
|
timeout=30,
|
|
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 "Claude / Codex — subscription" we drive the harness's own login
|
|
command (``claude auth login --claudeai`` / ``codex login``) **in the
|
|
foreground** (inheriting stdio so the OAuth / device-code prompts and any
|
|
browser URL 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) or ``"openai"``
|
|
(Codex).
|
|
: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
|
|
if shutil.which(spec.binary) is None:
|
|
return False
|
|
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 = [spec.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)
|