diff --git a/omnigent/cli_config.py b/omnigent/cli_config.py index 7caafb03..06d380d7 100644 --- a/omnigent/cli_config.py +++ b/omnigent/cli_config.py @@ -1345,6 +1345,7 @@ def _prompt_install_harness(family: str) -> bool: """ from omnigent.onboarding.configure_models import family_label from omnigent.onboarding.harness_install import ( + harness_cli_version, harness_install_command, install_harness_cli, ) @@ -1352,8 +1353,17 @@ def _prompt_install_harness(family: str) -> bool: label = family_label(family) cmd = " ".join(harness_install_command(family)) + detected_version, range_str = harness_cli_version(family) + if detected_version is None: + prompt = f"{label}'s CLI is missing. Install it now?" + else: + prompt = f"{label}'s CLI is installed ({detected_version}) but not supported." + if range_str: + prompt += f" Required version: {range_str}." + prompt += " Upgrade it now?" + choice = select( - f"{label}'s CLI isn't installed. Install it now?", + prompt, [ f"Yes — install ({cmd})", "No — back to harnesses", @@ -1396,9 +1406,10 @@ def _manage_harness_providers(family: str) -> None: from omnigent.onboarding.harness_install import harness_cli_installed from omnigent.onboarding.interactive import select - # If the harness CLI isn't installed, offer to install it before showing - # the credential menu. Declining (or copy-the-command) returns to the - # harness picker — there's nothing to configure for a harness you can't run. + # If the harness CLI is missing or on an unsupported version, offer to + # install/upgrade it before showing the credential menu. Declining (or + # copy-the-command) returns to the harness picker — there's nothing to + # configure for a harness you can't run. if not harness_cli_installed(family) and not _prompt_install_harness(family): return @@ -1651,17 +1662,20 @@ def _manage_cursor_harness() -> None: CURSOR_KEY, harness_cli_installed, harness_cli_logged_in, + harness_install_spec, ) from omnigent.onboarding.interactive import select while True: - cli_status = ( - "logged in" - if harness_cli_logged_in(CURSOR_KEY) - else "needs login" - if harness_cli_installed(CURSOR_KEY) - else "not installed" - ) + from omnigent._platform import resolve_cli_binary + + cli_installed = harness_cli_installed(CURSOR_KEY) + if cli_installed: + cli_status = "logged in" if harness_cli_logged_in(CURSOR_KEY) else "needs login" + elif resolve_cli_binary(harness_install_spec(CURSOR_KEY).binary) is not None: + cli_status = "needs upgrade" + else: + cli_status = "not installed" sdk_status = "API key configured" if cursor_api_key_configured() else "not configured" rows = [ _HarnessMenuRow(f"Cursor CLI — {cli_status}", action="cli"), @@ -3051,8 +3065,8 @@ def _manage_opencode_harness() -> None: synthesized into opencode's per-session config instead — set under Claude / Codex.) - OpenCode is npm-installable, so a missing CLI gates the drill-in with an - install offer. + OpenCode is npm-installable, so a missing or outdated CLI gates the + drill-in with an install/upgrade offer. :returns: None. Side effect: may ``npm install`` the opencode CLI. """ @@ -3067,7 +3081,7 @@ def _manage_opencode_harness() -> None: if not harness_cli_installed(OPENCODE_KEY): cmd = " ".join(harness_install_command(OPENCODE_KEY)) choice = select( - "OpenCode's CLI isn't installed. Install it now?", + "OpenCode's CLI is missing or on an unsupported version. Install/upgrade it now?", [ f"Yes — install ({cmd})", "No — back to harnesses", @@ -3273,6 +3287,20 @@ def _run_configure_harnesses_interactive() -> None: "action": ("", "cyan"), } + def _cli_absence_label(key: str) -> str: + """Return a status label that distinguishes "missing" from "outdated". + + When the binary is on PATH but ``harness_cli_installed`` is False, the + CLI is installed but on an unsupported version; saying "Not installed" + in that case is confusing for a user who knows they have the CLI. + """ + from omnigent._platform import resolve_cli_binary + + spec = harness_install_spec(key) + if spec is not None and resolve_cli_binary(spec.binary) is not None: + return "Needs upgrade" + return "Not installed" + def _install_hint(command: str) -> str: # Selection-only tooltip. The command is escaped so a bracketed extra # (e.g. ``pip install "omnigent[cursor]"``) renders literally instead of @@ -3304,7 +3332,7 @@ def _run_configure_harnesses_interactive() -> None: return ( fam, name, - "Not installed", + _cli_absence_label(fam), "missing", _install_hint(" ".join(harness_install_command(fam))), ) @@ -3376,7 +3404,7 @@ def _run_configure_harnesses_interactive() -> None: ( _OPENCODE, "OpenCode", - "Not installed", + _cli_absence_label(OPENCODE_KEY), "missing", _install_hint(" ".join(harness_install_command(OPENCODE_KEY))), ), @@ -3409,7 +3437,13 @@ def _run_configure_harnesses_interactive() -> None: else "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash" ) rows.append( - (_HERMES, "Hermes", "Not installed", "missing", _install_hint(hermes_hint)), + ( + _HERMES, + "Hermes", + _cli_absence_label(HERMES_KEY), + "missing", + _install_hint(hermes_hint), + ), ) elif hermes.ready: rows.append((_HERMES, "Hermes", hermes.describe(), "ready", "")) @@ -3459,7 +3493,7 @@ def _run_configure_harnesses_interactive() -> None: ( _QWEN, "Qwen Code", - "Not installed", + _cli_absence_label(QWEN_KEY), "missing", _install_hint(" ".join(harness_install_command(QWEN_KEY))), ), @@ -3485,7 +3519,15 @@ def _run_configure_harnesses_interactive() -> None: if goose_spec and goose_spec.install_hint else "brew install block-goose-cli" ) - rows.append((_GOOSE, "Goose", "Not installed", "missing", _install_hint(goose_hint))) + rows.append( + ( + _GOOSE, + "Goose", + _cli_absence_label(GOOSE_KEY), + "missing", + _install_hint(goose_hint), + ) + ) else: goose_summary = goose_config_summary() if goose_summary.provider: @@ -3535,7 +3577,9 @@ def _run_configure_harnesses_interactive() -> None: if kiro_spec and kiro_spec.install_hint else "curl -fsSL https://cli.kiro.dev/install | bash" ) - rows.append((_KIRO, "Kiro", "Not installed", "missing", _install_hint(kiro_hint))) + rows.append( + (_KIRO, "Kiro", _cli_absence_label(KIRO_KEY), "missing", _install_hint(kiro_hint)) + ) # Kimi Code — native CLI, own auth via `kimi login`; there is no local # login status probe yet. Curl-installed (no npm package), so use its @@ -3547,7 +3591,15 @@ def _run_configure_harnesses_interactive() -> None: else: kimi_spec = harness_install_spec(KIMI_KEY) kimi_hint = (kimi_spec.install_hint if kimi_spec else None) or "see Kimi Code docs" - rows.append((_KIMI, "Kimi Code", "Not installed", "missing", _install_hint(kimi_hint))) + rows.append( + ( + _KIMI, + "Kimi Code", + _cli_absence_label(KIMI_KEY), + "missing", + _install_hint(kimi_hint), + ) + ) # Custom ACP agents — the generic `acp` harness driving any user-configured # ACP-agent command. Each configured agent gets its own overview row diff --git a/omnigent/codex_native.py b/omnigent/codex_native.py index b1533254..0acbb359 100644 --- a/omnigent/codex_native.py +++ b/omnigent/codex_native.py @@ -37,7 +37,6 @@ from omnigent.claude_native_bridge import url_component from omnigent.codex_native_app_server import ( CodexAppServerClient, CodexNativeAppServer, - _find_codex_cli, build_codex_native_server, build_codex_remote_args, client_for_transport, @@ -64,6 +63,7 @@ from omnigent.entities.session_resources import terminal_resource_id from omnigent.harness_availability import ( HARNESS_BINARY_MISSING, HARNESS_NEEDS_AUTH, + HARNESS_VERSION_TOO_LOW, HarnessUnavailableReason, ) from omnigent.host.daemon_launch import ( @@ -188,6 +188,17 @@ def _codex_auth_json_has_available_credential(auth_path: Path) -> bool: return False +def _find_codex_cli() -> str | None: + """Return the resolved path to the Codex CLI binary, if any.""" + from omnigent._platform import resolve_cli_binary + from omnigent.onboarding.harness_install import OPENAI_FAMILY, harness_install_spec + + spec = harness_install_spec(OPENAI_FAMILY) + if spec is None: + return None + return resolve_cli_binary(spec.binary) + + def _codex_auth_unavailable_reason() -> HarnessUnavailableReason | None: """ Return why local Codex is unavailable, or ``None`` when available. @@ -216,8 +227,15 @@ def _codex_auth_unavailable_reason() -> HarnessUnavailableReason | None: Token *validity* (revoked/expired refresh, an unreachable gateway) is not judged locally — it surfaces at the first turn via the executor. """ + from omnigent.onboarding.harness_install import ( + OPENAI_FAMILY, + harness_cli_installed, + ) + if _find_codex_cli() is None: return HARNESS_BINARY_MISSING + if not harness_cli_installed(OPENAI_FAMILY): + return HARNESS_VERSION_TOO_LOW # On a host with no configured provider this may run ambient detection. # configured_harness_map shares one probe across all Codex aliases. try: diff --git a/omnigent/harness_availability.py b/omnigent/harness_availability.py index a479e08b..651eaa13 100644 --- a/omnigent/harness_availability.py +++ b/omnigent/harness_availability.py @@ -6,9 +6,16 @@ from typing import Final, Literal, TypeGuard HARNESS_BINARY_MISSING: Final[Literal["binary-missing"]] = "binary-missing" HARNESS_NEEDS_AUTH: Final[Literal["needs-auth"]] = "needs-auth" +HARNESS_VERSION_TOO_LOW: Final[Literal["version-too-low"]] = "version-too-low" -HarnessUnavailableReason = Literal["binary-missing", "needs-auth"] -HarnessAvailability = Literal[True, False, "binary-missing", "needs-auth"] +HarnessUnavailableReason = Literal["binary-missing", "needs-auth", "version-too-low"] +HarnessAvailability = Literal[ + True, + False, + "binary-missing", + "needs-auth", + "version-too-low", +] # Readiness and model-family checks must agree on every Codex spelling. CODEX_CANONICAL_HARNESSES: Final[frozenset[str]] = frozenset( @@ -18,4 +25,8 @@ CODEX_CANONICAL_HARNESSES: Final[frozenset[str]] = frozenset( def is_harness_availability(value: object) -> TypeGuard[HarnessAvailability]: """Return whether a decoded value is a supported readiness state.""" - return isinstance(value, bool) or value in (HARNESS_BINARY_MISSING, HARNESS_NEEDS_AUTH) + return isinstance(value, bool) or value in ( + HARNESS_BINARY_MISSING, + HARNESS_NEEDS_AUTH, + HARNESS_VERSION_TOO_LOW, + ) diff --git a/omnigent/harness_install_spec.py b/omnigent/harness_install_spec.py index 866a5657..92dbd377 100644 --- a/omnigent/harness_install_spec.py +++ b/omnigent/harness_install_spec.py @@ -24,6 +24,10 @@ class HarnessInstallSpec: login_status_key: str | None = None auth_hint: str | None = None install_command: tuple[str, ...] | None = None + min_version: str | None = None + """Minimum supported CLI version (inclusive).""" + max_version_exclusive: str | None = None + """Maximum supported CLI version (exclusive).""" @dataclass(frozen=True) diff --git a/omnigent/onboarding/harness_install.py b/omnigent/onboarding/harness_install.py index 075dac43..c27f3925 100644 --- a/omnigent/onboarding/harness_install.py +++ b/omnigent/onboarding/harness_install.py @@ -37,15 +37,22 @@ 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.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. @@ -69,6 +76,45 @@ KIMI_KEY = "kimi" # 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. @@ -109,6 +155,9 @@ _HARNESS_INSTALL: dict[str, HarnessInstallSpec] = { 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", @@ -117,13 +166,33 @@ _HARNESS_INSTALL: dict[str, HarnessInstallSpec] = { 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, ), - 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"), + # 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", @@ -136,6 +205,7 @@ _HARNESS_INSTALL: dict[str, HarnessInstallSpec] = { # 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", @@ -146,6 +216,9 @@ _HARNESS_INSTALL: dict[str, HarnessInstallSpec] = { 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 @@ -161,12 +234,16 @@ _HARNESS_INSTALL: dict[str, HarnessInstallSpec] = { login_args=("login",), logout_args=("logout",), 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`` has # no ``login`` / ``logout`` subcommand — the user authenticates via browser @@ -194,6 +271,7 @@ _HARNESS_INSTALL: dict[str, HarnessInstallSpec] = { "goose", package=None, install_hint="brew install block-goose-cli", + min_version=_GOOSE_MIN_VERSION, ), HERMES_KEY: HarnessInstallSpec( "Hermes", @@ -201,6 +279,7 @@ _HARNESS_INSTALL: dict[str, HarnessInstallSpec] = { package=None, install_hint=_HERMES_INSTALL_HINT, install_command=("bash", "-c", _HERMES_INSTALL_HINT), + min_version=_HERMES_MIN_VERSION, ), } @@ -514,26 +593,28 @@ def required_cli_for_harness(harness: str) -> HarnessInstallSpec | None: def missing_harness_cli(harness: str) -> HarnessInstallSpec | None: - """Return a harness's required CLI spec when that CLI can't be resolved. + """Return a harness's required CLI spec when that CLI can't be used. - Combines :func:`required_cli_for_harness` with the same - :func:`resolve_cli_binary` 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). 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. + 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``; ``None`` when the harness needs no CLI - (SDK-based / unknown) or the required binary is present. + ``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 - if resolve_cli_binary(spec.binary) is not 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 @@ -569,6 +650,79 @@ def harness_setup_hint(harness: str | None) -> str: 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 `` --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)) + + +def _harness_cli_version_satisfies(spec: HarnessInstallSpec, binary: str) -> 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. + """ + if spec.min_version is None and spec.max_version_exclusive is None: + return True + version = _harness_cli_version_string(spec, binary) + 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``. @@ -580,24 +734,102 @@ def harness_install_spec(key: str) -> HarnessInstallSpec | None: return _all_harness_install().get(key) -def harness_cli_installed(key: str) -> bool: - """Return whether the harness's CLI binary can be resolved. +def harness_cli_version_satisfies(key: str) -> bool: + """Return whether the installed CLI for *key* satisfies its version range. - "Installed" is deliberately the CLI binary (:func:`resolve_cli_binary` — - ``PATH`` plus the common global install dirs the host daemon's frozen - ``PATH`` may omit), matching ucode and the npm install-prompt UX — even - though the SDK-based ``claude-sdk`` harness can run without the ``claude`` - CLI. + 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 (``"anthropic"`` / ``"openai"``) or - :data:`PI_KEY` / :data:`KIMI_KEY`. - :returns: ``True`` when the CLI resolves; ``False`` when it doesn't or - the key has no associated CLI. + :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 - return resolve_cli_binary(spec.binary) is not None + 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) -> 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`. + :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) + + +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) -> str | None: + """Return the parsed, normalized version string from *binary* ``--version``.""" + try: + completed = subprocess.run( + [binary, "--version"], + capture_output=True, + text=True, + timeout=30, + 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]: diff --git a/omnigent/onboarding/harness_readiness.py b/omnigent/onboarding/harness_readiness.py index c7ab40e3..1425e8ea 100644 --- a/omnigent/onboarding/harness_readiness.py +++ b/omnigent/onboarding/harness_readiness.py @@ -33,6 +33,8 @@ from omnigent._platform import resolve_cli_binary from omnigent.harness_aliases import HARNESS_ALIASES, canonicalize_harness from omnigent.harness_availability import ( CODEX_CANONICAL_HARNESSES, + HARNESS_BINARY_MISSING, + HARNESS_VERSION_TOO_LOW, HarnessAvailability, ) from omnigent.harness_plugins import harness_install_keys, valid_harnesses @@ -47,6 +49,7 @@ from omnigent.onboarding.harness_install import ( PI_KEY, QWEN_KEY, harness_cli_installed, + harness_install_spec, required_cli_for_harness, ) from omnigent.onboarding.provider_config import ( @@ -180,23 +183,19 @@ def _install_key(canonical: str) -> str: return _HARNESS_FAMILY.get(canonical) or PI_KEY -def harness_is_configured(harness: str) -> bool: - """Return whether *harness* can be launched on this machine. +def _harness_availability_core(harness: str) -> HarnessAvailability: + """Return the detailed availability state for *harness*. - Only CLI-wrapping harnesses are assessed (native Claude/Codex/Kiro and - ``pi`` / ``pi-native``): they cannot run without their binary on - ``PATH``, and that is the one thing the daemon can check reliably and - locally. SDK harnesses and unknown harnesses always return ``True`` — - their readiness depends on runtime/ambient credentials the daemon - can't enumerate, so blocking them would risk false negatives that - break working launches. + Mirrors :func:`harness_is_configured` but preserves the distinction + between "CLI missing", "CLI present but version too old", and other + structured states so the web UI and setup dialogs can show actionable + copy. :param harness: A harness id, e.g. ``"claude-native"``, ``"codex"``, ``"openai-agents"``, ``"agents_sdk"``, ``"kiro-native"``, ``"pi"``, ``"pi-native"``, ``"qwen"``, or ``"qwen-code"``. - :returns: ``True`` when launchable (CLI installed, or a harness the - daemon doesn't gate); ``False`` only when a CLI-wrapping - harness's binary is missing from ``PATH``. + :returns: A :data:`HarnessAvailability` value.``True`` when launchable; + ``False`` or a reason string otherwise. """ canonical = _canonical_harness(harness) if canonical == "acp": @@ -214,26 +213,16 @@ def harness_is_configured(harness: str) -> bool: return True if canonical in _CURSOR_NATIVE_HARNESSES: # Native Cursor (``omni cursor``) wraps the ``cursor-agent`` CLI — gate - # on that binary, like ``claude-native`` / ``codex-native``. (Login - # state surfaces at run time; the daemon gates only on binary presence, - # mirroring the other native harnesses.) - return harness_cli_installed(CURSOR_KEY) + # on that binary. Keep the missing-binary case as the historical bare + # ``False`` sentinel, surfacing an outdated version only as + # ``"version-too-low"``. + return _installer_only_availability(CURSOR_KEY) if canonical in _KIRO_NATIVE_HARNESSES: - return harness_cli_installed(KIRO_KEY) + return _installer_only_availability(KIRO_KEY) if canonical in _GOOSE_NATIVE_HARNESSES or canonical == GOOSE_KEY: - # Goose — both the native TUI (``goose-native`` / ``native-goose``, via - # ``omni goose``) and the headless ACP harness (``goose``, drives - # ``goose acp``) — wraps the ``goose`` CLI, so gate on that binary. - # Auth/provider state surfaces at run time via Goose's own config; the - # daemon gates only on binary presence. - return harness_cli_installed(GOOSE_KEY) + return _installer_only_availability(GOOSE_KEY) if canonical in _HERMES_NATIVE_HARNESSES or canonical == HERMES_KEY: - # Hermes — both the native TUI (``hermes-native`` / ``native-hermes``, - # via ``omni hermes``) and the headless subprocess harness (``hermes``) - # — wraps the ``hermes`` CLI (installed via a curl script from Nous - # Research). Auth/provider config surfaces at run time via Hermes' own - # ``hermes model`` flow; gate only on binary presence. - return harness_cli_installed(HERMES_KEY) + return _installer_only_availability(HERMES_KEY) if canonical == CURSOR_KEY: # Cursor runs in-process via ``cursor-sdk`` and authenticates with a # ``CURSOR_API_KEY`` (a ``cursor-agent login`` does not apply). So, @@ -283,12 +272,13 @@ def harness_is_configured(harness: str) -> bool: # version skew). return True install_key = _install_key(canonical) - if not harness_cli_installed(install_key): - return False + availability = _installer_only_availability(install_key) # Families that authenticate via file-based credentials (not a CLI login # command) require both the binary AND a stored credential. The ``agy`` CLI # falls into this category: it has no ``agy login`` subcommand and writes # OAuth creds on the first interactive browser run instead. + if availability is not True: + return availability credential_check = _FAMILY_CREDENTIAL_CHECK.get(install_key) if credential_check is not None: return credential_check() @@ -306,10 +296,15 @@ def harness_is_configured(harness: str) -> bool: # an omnigent-managed provider). Qwen is absent on purpose: its key lives in the # harness's own env / interactive ``/auth``, which the daemon can't reduce to a # provider check, so it reports binary presence only. +# Cursor native is included here too: ``cursor-agent`` has its own login command, +# so the picker can distinguish "not installed" from "installed but not signed +# in", while the launch gate stays binary-only. _AUTH_AWARE_NATIVE_HARNESSES: dict[str, str] = { "claude-native": "anthropic", "native-claude": "anthropic", "opencode-native": OPENCODE_KEY, + "cursor-native": CURSOR_KEY, + "native-cursor": CURSOR_KEY, } @@ -351,15 +346,47 @@ def _family_provider_configured(harness: str) -> bool: return provider is not None and provider.kind != SUBSCRIPTION_KIND +def _installer_only_availability(install_key: str) -> HarnessAvailability: + """Return availability for a binary-gated harness without login commands. + + Mirrors :func:`_binary_availability_reason` but keeps the historical bare + ``False`` shape for a missing binary, so existing web/clients that expect a + simple boolean get that and only learn about structured reasons when the + binary is present but on an unsupported version. + """ + state = _binary_availability_reason(install_key) + if state == HARNESS_BINARY_MISSING: + return False + return state + + +def _binary_availability_reason(install_key: str) -> HarnessAvailability: + """Return the readiness reason when a CLI-backed harness can't be used. + + Distinguishes a genuinely missing CLI from one that is on ``PATH`` but + outside the version range the native harness requires. The latter is + exposed to the web UI as ``"version-too-low"`` so the user sees a prompt + to upgrade rather than "binary-missing". + """ + if harness_cli_installed(install_key): + return True + spec = harness_install_spec(install_key) + if spec is not None and resolve_cli_binary(spec.binary) is not None: + return HARNESS_VERSION_TOO_LOW + return HARNESS_BINARY_MISSING + + def _cli_family_availability(canonical: str, install_key: str) -> HarnessAvailability: """Two-step availability for a login-command CLI harness. :returns: ``"binary-missing"`` when the CLI isn't installed, + ``"version-too-low"`` when the CLI is present but too old, ``"needs-auth"`` when installed but neither a configured provider credential nor a CLI login is present, else ``True``. """ - if not harness_cli_installed(install_key): - return "binary-missing" + binary_state = _binary_availability_reason(install_key) + if binary_state is not True: + return binary_state if install_key == OPENCODE_KEY: from omnigent.onboarding.opencode_auth import opencode_auth_summary @@ -385,16 +412,46 @@ def _harness_availability(canonical: str) -> HarnessAvailability: return _codex_auth_unavailable_reason() or True install_key = _AUTH_AWARE_NATIVE_HARNESSES.get(canonical) if install_key is not None: + # Cursor is auth-aware like the other native CLI harnesses, so a missing + # binary surfaces as the structured ``"binary-missing"`` reason — not the + # bare ``False`` it historically reported. That keeps the picker badge / + # warning copy uniform across every CLI-backed native harness. return _cli_family_availability(canonical, install_key) if canonical in _PI_HARNESSES: # pi has no CLI login — its only credential is an omnigent-managed # provider (an API key / gateway, incl. one set from the UI). So the # two-step signal is binary + provider: installed-but-no-provider is # the yellow "needs-auth" state the setup dialog acts on. - if not harness_cli_installed(PI_KEY): - return "binary-missing" + binary_state = _binary_availability_reason(PI_KEY) + if binary_state is not True: + return binary_state return True if _family_provider_configured(PI_SURFACE) else "needs-auth" - return harness_is_configured(canonical) + return _harness_availability_core(canonical) + + +def harness_is_configured(harness: str) -> bool: + """Return whether *harness* can be launched on this machine. + + Only CLI-wrapping harnesses are assessed (native Claude/Codex/Kiro and + ``pi`` / ``pi-native``): they cannot run without their binary on + ``PATH``, and that is the one thing the daemon can check reliably and + locally. SDK harnesses and unknown harnesses always return ``True`` — + their readiness depends on runtime/ambient credentials the daemon + can't enumerate, so blocking them would risk false negatives that + break working launches. + + The check is binary-only: an installed-but-not-logged-in CLI still + returns ``True`` because auth failures surface at run time rather than + blocking dispatch. + + :param harness: A harness id, e.g. ``"claude-native"``, ``"codex"``, + ``"openai-agents"``, ``"agents_sdk"``, ``"kiro-native"``, ``"pi"``, + ``"pi-native"``, ``"qwen"``, or ``"qwen-code"``. + :returns: ``True`` when launchable (CLI installed, or a harness the + daemon doesn't gate); ``False`` when the binary is missing or on + an unsupported version. + """ + return _harness_availability_core(harness) is True def _is_codex_family_harness(canonical: str) -> bool: diff --git a/omnigent/runner/tool_dispatch.py b/omnigent/runner/tool_dispatch.py index 3dc5ca7a..c820a35e 100644 --- a/omnigent/runner/tool_dispatch.py +++ b/omnigent/runner/tool_dispatch.py @@ -1686,8 +1686,9 @@ async def _execute_subagent_tool( return ( f"Error: sub-agent {sub_agent_name!r} can't start on this " f"machine: harness {child_harness!r} needs the " - f"{missing_cli.binary!r} CLI on PATH, which was not found. " - f"Install it with: {install} " + f"{missing_cli.binary!r} CLI on PATH and on a supported " + f"version, but it is missing or outdated. " + f"Install/upgrade it with: {install} " f"(or don't dispatch to {sub_agent_name!r} here)." ) # Create child session on the server (no initial items — diff --git a/tests/cli/test_configure_models.py b/tests/cli/test_configure_models.py index ee1237d4..4ed1246a 100644 --- a/tests/cli/test_configure_models.py +++ b/tests/cli/test_configure_models.py @@ -1954,6 +1954,7 @@ def test_overview_status_color_distinguishes_missing_from_unconfigured( monkeypatch.setattr( "omnigent.onboarding.harness_install.harness_cli_installed", lambda family: False ) + monkeypatch.setattr("omnigent._platform.resolve_cli_binary", lambda _name: None) options, selectable, _descriptions, _compact, _max_visible = _capture_setup_overview( monkeypatch ) diff --git a/tests/e2e_ui/chat/test_cursor_setup_availability.py b/tests/e2e_ui/chat/test_cursor_setup_availability.py index 36086feb..1a6e7419 100644 --- a/tests/e2e_ui/chat/test_cursor_setup_availability.py +++ b/tests/e2e_ui/chat/test_cursor_setup_availability.py @@ -24,7 +24,7 @@ def _fulfill_hosts(route: Route) -> None: "name": _HOST_NAME, "owner": "e2e", "status": "online", - "configured_harnesses": {"cursor-native": False}, + "configured_harnesses": {"cursor-native": "binary-missing"}, } ] } @@ -85,10 +85,13 @@ def test_cursor_missing_cli_shows_install_and_login_guidance( warning = page.get_by_test_id("new-chat-landing-harness-warning") expect(warning).to_be_visible(timeout=30_000) - expect(warning).to_contain_text(f"Cursor needs cursor-agent on {_HOST_NAME}") - expect(warning).to_contain_text("curl https://cursor.com/install -fsS | bash") - expect(warning).to_contain_text("cursor-agent login") - expect(warning.locator("code")).to_have_count(2) + # A missing cursor-agent binary now reports the uniform "binary-missing" + # reason — the same structured signal as Codex / Claude / OpenCode — so the + # feature-OFF warning is the generic "run omni setup" guidance, not the + # Cursor-specific curl instructions (those live in the setup dialog when the + # install feature is ON, and in `omni cursor` on the CLI). + expect(warning).to_contain_text(f"Cursor isn't configured on {_HOST_NAME}") + expect(warning).to_contain_text("omni setup") # The guidance is visible before launch and remains warning-only. composer.fill("help me inspect this repository") @@ -98,9 +101,8 @@ def test_cursor_missing_cli_shows_install_and_login_guidance( page.get_by_test_id("new-chat-landing-agent-select").click() badge = page.get_by_test_id(f"new-chat-landing-agent-warning-{_AGENT_ID}") expect(badge).to_be_visible() - # This test doesn't enable OMNIGENT_HARNESS_INSTALL_ENABLED, so the picker - # runs on the feature-OFF default — where the badge keeps the original - # per-reason text ("install & login" for a missing cursor-agent CLI). (With - # the feature ON the badge collapses to a single "needs setup" and the steps - # move into the setup dialog.) - expect(badge).to_have_text("install & login") + # Feature-OFF picker keeps the per-reason badge text; with the structured + # "binary-missing" reason that is now "binary missing" — the same label + # Codex / Claude / OpenCode show for a missing CLI, instead of the legacy + # bespoke "install & login". + expect(badge).to_have_text("binary missing") diff --git a/tests/e2e_ui/chat/test_version_too_low_availability.py b/tests/e2e_ui/chat/test_version_too_low_availability.py new file mode 100644 index 00000000..bd3db5bc --- /dev/null +++ b/tests/e2e_ui/chat/test_version_too_low_availability.py @@ -0,0 +1,132 @@ +"""E2E: version-too-low warning in the New Chat landing screen. + +The composer warns when a native harness is installed but its CLI version is +below the supported minimum (``"version-too-low"``). This test verifies the +NewChatDialog renders the updated copy: an under-composer message mentioning +"outdated CLI". +""" + +from __future__ import annotations + +import asyncio +import json +import re +import threading +from collections.abc import Coroutine +from typing import Any + +from playwright.async_api import Route, async_playwright, expect + +_HOST_ID = "host_version_e2e" +_HOST_NAME = "version-e2e-host" + + +def _run_in_fresh_loop(coro: Coroutine[Any, Any, None]) -> None: + captured: dict[str, Exception] = {} + + def _worker() -> None: + try: + asyncio.run(coro) + except Exception as exc: + captured["error"] = exc + + thread = threading.Thread(target=_worker) + thread.start() + thread.join() + if "error" in captured: + raise captured["error"] + + +def _codex_native_agents_body() -> str: + return json.dumps( + { + "data": [ + { + "id": "ag_codex_version_e2e", + "name": "codex-native-ui", + "display_name": "Codex", + "description": "OpenAI's coding agent", + "harness": "codex-native", + "skills": [], + } + ] + } + ) + + +async def _register_routes(page, *, configured_harnesses: dict[str, Any]) -> None: + async def handle_hosts(route: Route) -> None: + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps( + { + "hosts": [ + { + "host_id": _HOST_ID, + "name": _HOST_NAME, + "owner": "e2e", + "status": "online", + "configured_harnesses": configured_harnesses, + } + ] + } + ), + ) + + async def handle_agents(route: Route) -> None: + await route.fulfill( + status=200, + content_type="application/json", + body=_codex_native_agents_body(), + ) + + async def handle_agent_scan(route: Route) -> None: + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"data": []}), + ) + + await page.route("**/v1/hosts", handle_hosts) + await page.route("**/v1/agents", handle_agents) + await page.route(re.compile(r"/v1/sessions\?.*kind=any"), handle_agent_scan) + + +def test_version_too_low_warns_with_outdated_cli_copy( + seeded_session: tuple[str, str], +) -> None: + """A version-too-low Codex host renders the outdated CLI warning.""" + base_url, session_id = seeded_session + del session_id + _run_in_fresh_loop(_drive_version_too_low(base_url)) + + +async def _drive_version_too_low(base_url: str) -> None: + async with async_playwright() as pw: + browser = await pw.chromium.launch() + page = await browser.new_page() + try: + await _register_routes( + page, + configured_harnesses={"codex-native": "version-too-low"}, + ) + await page.add_init_script( + f"""window.localStorage.setItem( + "omnigent:recent-workspaces", + JSON.stringify({{ {_HOST_ID!r}: ["/work/repo"] }}) + );""" + ) + + await page.goto(f"{base_url}/") + await page.get_by_test_id("new-chat-landing-input").wait_for( + state="visible", timeout=30_000 + ) + + warning = page.get_by_test_id("new-chat-landing-harness-warning") + await expect(warning).to_be_visible(timeout=30_000) + await expect(warning).to_contain_text("has an outdated CLI") + await expect(warning).to_contain_text(_HOST_NAME) + await expect(warning).to_contain_text("omni setup") + finally: + await browser.close() diff --git a/tests/onboarding/test_harness_install.py b/tests/onboarding/test_harness_install.py index cc436e6b..689a5127 100644 --- a/tests/onboarding/test_harness_install.py +++ b/tests/onboarding/test_harness_install.py @@ -24,9 +24,23 @@ def _stub_cli_fallback_dirs(monkeypatch: pytest.MonkeyPatch) -> None: binary's presence/absence; stub the fallback dirs to empty too so a developer's real claude/codex install can't flip a ``which``-returns-None assertion. + + Also stub ``--version`` probes so tests that simply need "binary present" + are not tripped up by an unexpected subprocess call once a harness spec + declares a version floor. Tests that care about the version can override + the stub explicitly. """ monkeypatch.setattr(_platform, "_cli_fallback_dirs", lambda: ()) + def _stub_version_run(argv: list[str], **k: object) -> subprocess.CompletedProcess[str]: + if len(argv) >= 2 and argv[1] == "--version": + return subprocess.CompletedProcess( + args=argv, returncode=0, stdout="9.9.9\n", stderr="" + ) + raise AssertionError(f"unexpected subprocess in harness_install tests: {argv!r}") + + monkeypatch.setattr(hi.subprocess, "run", _stub_version_run) + @pytest.mark.parametrize( "key,binary,package", @@ -457,11 +471,14 @@ def test_try_install_harness_cli_success_when_binary_off_path( # npm is on PATH; the installed codex binary never is — only the ladder finds it. monkeypatch.setattr(hi.shutil, "which", lambda name: "/usr/bin/npm" if name == "npm" else None) monkeypatch.setattr(_platform, "_cli_fallback_dirs", lambda: (fallback_dir,)) - monkeypatch.setattr( - hi.subprocess, - "run", - lambda argv, **k: subprocess.CompletedProcess(args=argv, returncode=0), - ) + + def _run(argv: list[str], **k: object) -> subprocess.CompletedProcess[str]: + if len(argv) >= 2 and argv[1] == "--version": + out = "9.9.9\n" + return subprocess.CompletedProcess(args=argv, returncode=0, stdout=out, stderr="") + return subprocess.CompletedProcess(args=argv, returncode=0) + + monkeypatch.setattr(hi.subprocess, "run", _run) # Install verdict agrees with readiness: both see it installed. assert hi.try_install_harness_cli(OPENAI_FAMILY) == (True, None) @@ -1026,3 +1043,224 @@ def test_ui_setup_steps_generic_for_non_installable() -> None: assert steps[0].action == "setup" assert steps[0].command == "omni setup" assert steps[0].status_key is None + + +# ── Version-aware installed check ──────────────────────── + + +@pytest.mark.parametrize( + "key,min_version,max_version_exclusive", + [ + (hi.OPENCODE_KEY, "1.17.7", "1.18.0"), + (hi.CURSOR_KEY, "2026.06.02", None), + (hi.KIMI_KEY, "1.47.0", None), + (ANTHROPIC_FAMILY, "2.1.161", None), + (OPENAI_FAMILY, "0.137.0", None), + (hi.PI_KEY, "0.79.0", None), + (hi.QWEN_KEY, "0.18.1", None), + (hi.GOOSE_KEY, "1.38.0", None), + (hi.HERMES_KEY, "2026.06.05", None), + (hi.KIRO_KEY, "2.10.0", None), + ], +) +def test_versioned_specs_declare_bounds( + key: str, min_version: str, max_version_exclusive: str | None +) -> None: + """Version-bounded harness specs expose the same floors setup enforces.""" + spec = hi.harness_install_spec(key) + assert spec is not None + assert spec.min_version == min_version + assert spec.max_version_exclusive == max_version_exclusive + + +@pytest.mark.parametrize( + "version,expected", + [ + ("1.17.6", False), # below min + ("1.18.0", False), # at max exclusive + ("2.0.0", False), # above max + ("1.17.8", True), # inside range + ], +) +def test_harness_cli_installed_checks_version_for_versioned_specs( + monkeypatch: pytest.MonkeyPatch, version: str, expected: bool +) -> None: + """A present CLI whose ``--version`` is outside the declared range reads as + not installed, so setup prompts for an upgrade before the runtime gate.""" + monkeypatch.setattr(hi.shutil, "which", lambda name: f"/usr/bin/{name}") + + def _run(argv: list[str], **k: object) -> subprocess.CompletedProcess[str]: + if len(argv) >= 2 and argv[1] == "--version": + # OpenCode's supported range is [1.17.7, 1.18.0). + return subprocess.CompletedProcess( + args=argv, returncode=0, stdout=f"{version}\n", stderr="" + ) + raise AssertionError(f"unexpected subprocess: {argv!r}") + + monkeypatch.setattr(hi.subprocess, "run", _run) + assert hi.harness_cli_installed(hi.OPENCODE_KEY) is expected + + +@pytest.mark.parametrize( + "key", + [ + hi.CURSOR_KEY, + hi.KIMI_KEY, + ANTHROPIC_FAMILY, + OPENAI_FAMILY, + hi.PI_KEY, + hi.QWEN_KEY, + hi.GOOSE_KEY, + hi.HERMES_KEY, + hi.KIRO_KEY, + ], +) +def test_harness_cli_installed_checks_minimum_for_other_versioned_specs( + monkeypatch: pytest.MonkeyPatch, key: str +) -> None: + """Version-bounded harnesses treat a CLI older than their declared floor as + not installed, so setup prompts for an upgrade.""" + monkeypatch.setattr(hi.shutil, "which", lambda name: f"/usr/bin/{name}") + + def _run(argv: list[str], **k: object) -> subprocess.CompletedProcess[str]: + if len(argv) >= 2 and argv[1] == "--version": + out = "0.0.1\n" + return subprocess.CompletedProcess(args=argv, returncode=0, stdout=out, stderr="") + raise AssertionError(f"unexpected subprocess: {argv!r}") + + monkeypatch.setattr(hi.subprocess, "run", _run) + assert hi.harness_cli_installed(key) is False + + +def test_harness_cli_installed_true_when_version_in_range( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A present CLI with a satisfying version reads as installed.""" + monkeypatch.setattr(hi.shutil, "which", lambda name: f"/usr/bin/{name}") + + def _run(argv: list[str], **k: object) -> subprocess.CompletedProcess[str]: + if len(argv) >= 2 and argv[1] == "--version": + out = "1.17.8\n" + return subprocess.CompletedProcess(args=argv, returncode=0, stdout=out, stderr="") + raise AssertionError(f"unexpected subprocess: {argv!r}") + + monkeypatch.setattr(hi.subprocess, "run", _run) + assert hi.harness_cli_installed(hi.OPENCODE_KEY) is True + + +def test_harness_cli_installed_ignores_upper_bound_for_unversioned_specs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Harnesses without a version declaration are not probed with ``--version``.""" + monkeypatch.setattr(hi.shutil, "which", lambda name: f"/usr/bin/{name}") + + def _explode(*a: object, **k: object) -> None: + raise AssertionError("version probe spawned for an unversioned harness") + + monkeypatch.setattr(hi.subprocess, "run", _explode) + assert hi.harness_cli_installed(GEMINI_FAMILY) is True + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("cursor-agent 2026.07.01-777f564", "2026.07.01"), + ("2026.06.19-20-24-33-653a7fb", "2026.06.19"), + ("2026.05.24.1.dda726e", "2026.05.24"), + ("kimi version 1.47.0", "1.47.0"), + ("1.17.7-rc1", "1.17.7-rc1"), + ], +) +def test_parse_harness_cli_version_normalizes_date_versions(raw: str, expected: str) -> None: + """Date-shaped Cursor versions are stripped to ``YYYY.MM.DD`` so PEP 440 can + compare them; normal semver versions stay unchanged.""" + assert hi._parse_harness_cli_version(raw) == expected + + +@pytest.mark.parametrize( + "key,outdated,satisfying", + [ + (hi.CURSOR_KEY, "2026.05.24", "2026.06.22"), + (hi.KIMI_KEY, "1.46.0", "1.48.0"), + (hi.HERMES_KEY, "2026.05.29", "2026.06.19"), + ], +) +def test_harness_cli_installed_enforces_default_post_2026_06_01_floors( + monkeypatch: pytest.MonkeyPatch, + key: str, + outdated: str, + satisfying: str, +) -> None: + """Cursor and Kimi default to the first release after 2026-06-01.""" + monkeypatch.setattr(hi.shutil, "which", lambda name: f"/usr/bin/{name}") + + def _run(argv: list[str], **k: object) -> subprocess.CompletedProcess[str]: + if len(argv) >= 2 and argv[1] == "--version": + return subprocess.CompletedProcess( + args=argv, returncode=0, stdout=f"{outdated}\n", stderr="" + ) + raise AssertionError(f"unexpected subprocess: {argv!r}") + + monkeypatch.setattr(hi.subprocess, "run", _run) + assert hi.harness_cli_installed(key) is False + + def _run_ok(argv: list[str], **k: object) -> subprocess.CompletedProcess[str]: + if len(argv) >= 2 and argv[1] == "--version": + return subprocess.CompletedProcess( + args=argv, returncode=0, stdout=f"{satisfying}\n", stderr="" + ) + raise AssertionError(f"unexpected subprocess: {argv!r}") + + monkeypatch.setattr(hi.subprocess, "run", _run_ok) + assert hi.harness_cli_installed(key) is True + + +def test_harness_cli_installed_false_when_version_unparseable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A present CLI whose ``--version`` output contains no parseable version is + treated as not installed, so setup prompts for an upgrade.""" + monkeypatch.setattr(hi.shutil, "which", lambda name: f"/usr/bin/{name}") + + def _run(argv: list[str], **k: object) -> subprocess.CompletedProcess[str]: + if len(argv) >= 2 and argv[1] == "--version": + return subprocess.CompletedProcess( + args=argv, returncode=0, stdout="dev-SNAPSHOT\n", stderr="" + ) + raise AssertionError(f"unexpected subprocess: {argv!r}") + + monkeypatch.setattr(hi.subprocess, "run", _run) + assert hi.harness_cli_installed(hi.OPENCODE_KEY) is False + + +def test_harness_cli_version_satisfies_short_circuits_when_binary_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``harness_cli_version_satisfies`` returns False when the binary is absent + without shelling out to a missing executable.""" + monkeypatch.setattr(hi.shutil, "which", lambda name: None) + + def _explode(*a: object, **k: object) -> None: + raise AssertionError("version probe spawned despite missing binary") + + monkeypatch.setattr(hi.subprocess, "run", _explode) + assert hi.harness_cli_version_satisfies(hi.OPENCODE_KEY) is False + + +def test_missing_harness_cli_flags_outdated_version( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A CLI present but outside its declared version range is treated as + missing by the dispatch preflight, so the runner fails loud before launch.""" + monkeypatch.setattr(hi.shutil, "which", lambda name: f"/usr/bin/{name}") + + def _run(argv: list[str], **k: object) -> subprocess.CompletedProcess[str]: + if len(argv) >= 2 and argv[1] == "--version": + out = "1.16.0\n" + return subprocess.CompletedProcess(args=argv, returncode=0, stdout=out, stderr="") + raise AssertionError(f"unexpected subprocess: {argv!r}") + + monkeypatch.setattr(hi.subprocess, "run", _run) + spec = hi.missing_harness_cli("opencode-native") + assert spec is not None + assert spec.binary == "opencode" diff --git a/tests/onboarding/test_harness_readiness.py b/tests/onboarding/test_harness_readiness.py index 52605028..9f4afacf 100644 --- a/tests/onboarding/test_harness_readiness.py +++ b/tests/onboarding/test_harness_readiness.py @@ -2,12 +2,14 @@ from __future__ import annotations +import subprocess from pathlib import Path import pytest import yaml import omnigent.onboarding.harness_install as hi +from omnigent.harness_availability import HARNESS_VERSION_TOO_LOW from omnigent.onboarding.harness_readiness import ( configured_harness_map, harness_is_configured, @@ -48,6 +50,29 @@ def _all_clis_installed(monkeypatch: pytest.MonkeyPatch) -> None: # shutil.which (reverted by monkeypatch after the test). monkeypatch.setattr(hi.shutil, "which", lambda name: f"/usr/bin/{name}") + # Some harnesses (OpenCode) now validate the CLI's ``--version``. Stub a + # satisfying version so tests that simply need "binary present" are not + # tripped up by an unexpected subprocess probe. + def _stub_run(argv: list[str], **k: object) -> subprocess.CompletedProcess[str]: + if len(argv) >= 2 and argv[1] == "--version": + # OpenCode's declared range is [1.17.7, 1.18.0); Cursor uses calendar + # versions and needs a build after 2026-06-01; everything else is + # fine with a generous semver placeholder. + if argv[0].endswith("opencode"): + version = "1.17.7\n" + elif argv[0].endswith("cursor-agent") or argv[0].endswith("hermes"): + version = "2026.07.01\n" + else: + version = "9.9.9\n" + return subprocess.CompletedProcess(args=argv, returncode=0, stdout=version, stderr="") + raise AssertionError(f"unexpected subprocess during readiness tests: {argv!r}") + + monkeypatch.setattr(hi.subprocess, "run", _stub_run) + # Auth-aware native harnesses (now including Cursor native) check login state + # in the picker map. Treat them as logged in when the test just needs + # "binary present". + monkeypatch.setattr(hi, "harness_cli_logged_in", lambda _key: True) + def _no_clis_installed(monkeypatch: pytest.MonkeyPatch) -> None: """Make every harness CLI binary appear missing. @@ -352,8 +377,6 @@ def test_configured_harness_map_gates_only_cli_harnesses( # binary it reads False before its credential check is even reached. for cli in ( "kimi", - "cursor-native", - "native-cursor", "kiro-native", "native-kiro", "antigravity-native", @@ -363,11 +386,13 @@ def test_configured_harness_map_gates_only_cli_harnesses( "qwen", "hermes", ): - assert result[cli] is False, f"{cli} should be gated on its CLI binary" - # Auth-aware harnesses (codex, claude, opencode, pi) carry a two-step signal - # in the picker map, so a missing binary is the structured ``"binary-missing"`` - # (step 1 to-do), not a bare ``False``. Pi joined this group — it now reports - # the credential axis (no CLI login; its credential is a provider). + assert result[cli] is not True, f"{cli} should be gated on its CLI binary" + # Auth-aware harnesses (codex, claude, opencode, cursor, pi) carry a + # two-step signal in the picker map, so a missing binary is the structured + # ``"binary-missing"`` (step 1 to-do), not a bare ``False``. Cursor joined + # this group — it is now auth-aware like the other native CLI harnesses, so + # its missing binary surfaces as ``"binary-missing"`` too. Pi is also here — + # it reports the credential axis (no CLI login; its credential is a provider). for missing in ( "codex", "codex-native", @@ -375,6 +400,8 @@ def test_configured_harness_map_gates_only_cli_harnesses( "claude-native", "native-claude", "opencode-native", + "cursor-native", + "native-cursor", "pi", "pi-native", ): @@ -512,3 +539,49 @@ def test_native_cursor_keys_off_binary_not_api_key( monkeypatch.delenv("CURSOR_API_KEY", raising=False) assert harness_is_configured("cursor-native") is True assert harness_is_configured("native-cursor") is True + + +def test_configured_harness_map_reports_version_too_low_for_outdated_clis( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An outdated CLI for major native harnesses is flagged ``version-too-low``. + + This exercises the readiness-layer wiring, which is where the binary is + on ``PATH`` but does not satisfy the declared ``min_version`` of the spec. + The core promise of the feature is that users see an upgrade prompt instead + of being told the binary is missing. + """ + monkeypatch.setattr(hi.shutil, "which", lambda name: f"/usr/bin/{name}") + monkeypatch.setattr(hi, "harness_cli_installed", lambda _key: False) + monkeypatch.setattr(hi, "harness_cli_logged_in", lambda _key: True) + result = configured_harness_map() + for harness in ( + "claude-native", + "native-claude", + "opencode-native", + "native-opencode", + "cursor-native", + "native-cursor", + "kiro-native", + "native-kiro", + ): + assert result[harness] == HARNESS_VERSION_TOO_LOW, ( + f"{harness} should report version-too-low, not binary-missing" + ) + + +def test_antigravity_native_requires_credential( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``antigravity-native`` needs both the ``agy`` binary and a stored credential.""" + import omnigent.onboarding.gemini_auth as _ga + + _all_clis_installed(monkeypatch) + # Binary installed but no credential → not ready. + monkeypatch.setattr(_ga, "gemini_login_detected", lambda: False) + assert harness_is_configured("antigravity-native") is False + assert harness_is_configured("native-antigravity") is False + # Stored credential present → ready. + monkeypatch.setattr(_ga, "gemini_login_detected", lambda: True) + assert harness_is_configured("antigravity-native") is True + assert harness_is_configured("native-antigravity") is True diff --git a/tests/test_codex_native.py b/tests/test_codex_native.py index 9dd11043..a32cea33 100644 --- a/tests/test_codex_native.py +++ b/tests/test_codex_native.py @@ -64,6 +64,14 @@ def _point_codex_auth_check_at( "_find_codex_cli", lambda: "/tmp/codex" if binary_present else None, ) + if binary_present: + # The auth check now also validates the installed version. Treat a + # present binary as satisfying the version check so the tests focus + # on the auth-path decision. + monkeypatch.setattr( + "omnigent.onboarding.harness_install.harness_cli_installed", + lambda _key: True, + ) def test_codex_auth_unavailable_reason_binary_missing( diff --git a/web/src/lib/harnessSetup.test.ts b/web/src/lib/harnessSetup.test.ts index f947a201..b36926b2 100644 --- a/web/src/lib/harnessSetup.test.ts +++ b/web/src/lib/harnessSetup.test.ts @@ -103,14 +103,22 @@ describe("harnessUnavailableReasonOnHost", () => { "needs-auth", ); expect( - harnessUnavailableReasonOnHost("cursor-native", hostWith({ "cursor-native": false })), - ).toBe("cursor-cli-missing"); + harnessUnavailableReasonOnHost( + "cursor-native", + hostWith({ "cursor-native": "binary-missing" }), + ), + ).toBe("binary-missing"); expect(harnessUnavailableReasonOnHost("pi", hostWith({ pi: false }))).toBe("unconfigured"); + expect(harnessUnavailableReasonOnHost("codex", hostWith({ codex: "version-too-low" }))).toBe( + "version-too-low", + ); }); it("returns null when ready, unknown, or no host", () => { expect(harnessUnavailableReasonOnHost("codex", hostWith({ codex: true }))).toBe(null); - expect(harnessUnavailableReasonOnHost("codex", hostWith({ codex: "future" }))).toBe(null); + expect(harnessUnavailableReasonOnHost("codex", hostWith({ codex: "future" }))).toBe( + "unconfigured", + ); expect(harnessUnavailableReasonOnHost("codex", hostWith(null))).toBe(null); expect(harnessUnavailableReasonOnHost(null, hostWith({ codex: false }))).toBe(null); }); @@ -225,6 +233,14 @@ describe("resolveSetupSteps", () => { expect(steps.map((s) => s.status)).toEqual(["done", "todo"]); }); + it("marks install todo + auth todo when the binary is present but too old", () => { + const steps = resolveSetupSteps(CODEX_STEPS, "codex", hostWith({ codex: "version-too-low" })); + expect(steps.map((s) => [s.kind, s.status])).toEqual([ + ["install", "todo"], + ["auth", "todo"], + ]); + }); + it("marks both done when the harness is ready", () => { const steps = resolveSetupSteps(CODEX_STEPS, "codex", hostWith({ codex: true })); expect(steps.map((s) => s.status)).toEqual(["done", "done"]); diff --git a/web/src/lib/harnessSetup.ts b/web/src/lib/harnessSetup.ts index f9f1893e..c9b0ae6f 100644 --- a/web/src/lib/harnessSetup.ts +++ b/web/src/lib/harnessSetup.ts @@ -43,7 +43,7 @@ export function isCodexHarness(harness: string): boolean { return harness === "codex" || harness === "codex-native" || harness === "native-codex"; } -function isNativeCursorHarness(harness: string): boolean { +export function isNativeCursorHarness(harness: string): boolean { return harness === "cursor-native" || harness === "native-cursor"; } @@ -61,15 +61,23 @@ export function harnessUnavailableReasonOnHost( const availability = host.configured_harnesses[harness]; if (availability === false) { if (isCodexHarness(harness)) return "binary-missing"; - if (isNativeCursorHarness(harness)) return "cursor-cli-missing"; return "unconfigured"; } // Auth-aware CLI harnesses (codex, claude, opencode) report a structured - // string when installed-but-not-ready. - if (availability === "binary-missing" || availability === "needs-auth") { + // string when installed-but-not-ready. "version-too-low" can surface for + // any CLI-backed harness whose binary is present but too old. + if ( + availability === "binary-missing" || + availability === "needs-auth" || + availability === "version-too-low" + ) { return availability; } - // Unknown future reason strings fall through to no warning until the UI knows their copy. + // Any other string from a newer/older server still means "not ready"; + // show a generic warning rather than silently treating it as available. + if (typeof availability === "string") { + return "unconfigured"; + } return null; } @@ -97,7 +105,7 @@ export function harnessWarningBadgeText(reason: string | null, collapsed = false if (collapsed) return "needs setup"; if (reason === "binary-missing") return "binary missing"; if (reason === "needs-auth") return "needs auth"; - if (reason === "cursor-cli-missing") return "install & login"; + if (reason === "version-too-low") return "outdated"; return "needs setup"; } @@ -195,7 +203,10 @@ function stepStatus( availability: boolean | string | undefined, ): SetupStepStatus { if (statusKey === null || availability === undefined) return "unknown"; - const notInstalled = availability === false || availability === "binary-missing"; + const notInstalled = + availability === false || + availability === "binary-missing" || + availability === "version-too-low"; if (statusKey === "installed") return notInstalled ? "todo" : "done"; if (statusKey === "authed") return availability === true ? "done" : "todo"; return "unknown"; diff --git a/web/src/shell/NewChatDialog.test.tsx b/web/src/shell/NewChatDialog.test.tsx index ea931d16..d8a2f776 100644 --- a/web/src/shell/NewChatDialog.test.tsx +++ b/web/src/shell/NewChatDialog.test.tsx @@ -499,9 +499,9 @@ describe("harnessUnconfiguredOnHost", () => { expect(harnessUnavailableReasonOnHost("codex-native", testHost)).toBe("binary-missing"); }); - it("ignores unknown future reason strings", () => { + it("falls back to a generic warning for unknown reason strings", () => { expect(harnessUnavailableReasonOnHost("codex", hostWith({ codex: "future-reason" }))).toBe( - null, + "unconfigured", ); }); diff --git a/web/src/shell/NewChatDialog.tsx b/web/src/shell/NewChatDialog.tsx index ecf022bc..1ec428ed 100644 --- a/web/src/shell/NewChatDialog.tsx +++ b/web/src/shell/NewChatDialog.tsx @@ -76,6 +76,7 @@ import { harnessUnconfiguredOnHost, harnessWarningBadgeText, isCodexHarness, + isNativeCursorHarness, } from "@/lib/harnessSetup"; // Re-exported for tests that import the readiness helpers from this module. @@ -549,15 +550,6 @@ function harnessWarningMessage( reason: string | null, harness: string | null | undefined, ): ReactNode { - if (reason === "cursor-cli-missing") { - return ( - <> - {agentName} needs cursor-agent on {hostName} — install it with{" "} - curl https://cursor.com/install -fsS | bash, then run{" "} - cursor-agent login. - - ); - } const isCodex = !!harness && isCodexHarness(harness); if (reason === "needs-auth" && isCodex) { return ( @@ -567,12 +559,22 @@ function harnessWarningMessage( ); } - if (reason === "binary-missing" && isCodex) { + if (reason === "needs-auth" && !!harness && isNativeCursorHarness(harness)) { return ( <> - {agentName} can't find the Codex binary on {hostName} — if codex is installed, restart - the host with omnigent host so it picks up your PATH, or set{" "} - OMNIGENT_CODEX_PATH. Otherwise run omni setup. + {agentName} needs Cursor login on {hostName} — run cursor-agent login on that + machine. + + ); + } + // ``version-too-low`` is a uniform state across all CLI harnesses now that + // the server checks supported version ranges. Keep the message generic so + // the user is nudged toward setup rather than being told the CLI is missing. + if (reason === "version-too-low") { + return ( + <> + {agentName} has an outdated CLI on {hostName} — run omni setup, or upgrade the + CLI directly on that machine. ); }