Compare commits

...

7 Commits

Author SHA1 Message Date
SabhyaC26 f5c035f527 style(setup): widen the name→status gutter slightly
Bump the harness-name column gutter from 2 to 4 spaces so the status sits a
touch further from the longest name and the table breathes a bit more.
2026-06-26 05:31:36 +00:00
SabhyaC26 58e4072e60 fix(setup): unify installed-but-unconfigured status as "Not configured"
Replace the per-harness "No API key" / "No Gemini key" / "No credential" /
"No provider" / "No auth" / "No token" warn statuses with a single, consistent
"Not configured" message (parallel to "Not installed"). The yellow ✗ still
distinguishes it from a missing CLI, and each row's selection-only hint keeps
the specific next step.
2026-06-26 05:27:06 +00:00
SabhyaC26 ced0533fc2 fix(setup): refine harness overview — no underline, aligned status, tighter spacing
Address UX feedback on the compact overview:
- Drop the underline on the highlighted row; the ❯ pointer + bold accent is
  the highlight (revert the compact underline).
- Left-align the status into a single column a fixed gutter right of the
  names so every ✓/✗ glyph lines up vertically (the right-aligned status
  scattered the glyphs and read as messy).
- Remove the credential-search spinner from setup: it left a cleared-region
  gap and a residual line above the menu on first paint. The detection is
  fast and the callout still prints.
- Hug the menu title to the list (no blank line below it) in the compact
  overview, and show a navigate/select/exit footer in the spirit of other
  modern CLIs (top-level Esc exits; nested menus keep "Esc back").
2026-06-26 05:27:06 +00:00
SabhyaC26 8732fce613 test(setup): pin overview dispatch + status color; harden status markup
Address review feedback on the compact harness overview:
- Add an end-to-end dispatch test (parametrized over the 7 harness positions
  no scripted-stdin test covered) so a wrong sentinel in a hand-written row
  tuple is caught instead of slipping past the name-only ordering test.
- Assert the status color taxonomy (red ✗ "Not installed" vs yellow ✗ "No
  credential") and add the Copilot selection-only install-hint test, matching
  the Cursor / Antigravity coverage.
- Escape the interpolated status text (parity with the descriptions) and cap
  its width so a verbose row can't widen/wrap the shared status column on a
  narrow terminal; fold the width pass into a single loop.
2026-06-26 05:27:06 +00:00
SabhyaC26 b80afd1877 feat(setup): compact all-visible harness overview
Replace the "More harnesses" fold with a single compact row per harness:
the name on the left and a right-aligned ✓/✗ status on the right (the
configured credential, or "Not installed" / "No credential"). Every harness
is visible at once, in 0.3 priority order (Claude, Codex, Cursor, OpenCode,
Hermes, Pi, then Antigravity, Qwen Code, Goose, Copilot, Kiro, Kimi Code).

The actionable install command / next-step hint now renders only for the
highlighted row, as the selector's description line, so the overview stays
uncluttered. The selected row gains an underline (new ``select(compact=...)``)
so the highlight is unmistakable in the dense single-line list.
2026-06-26 05:27:06 +00:00
SabhyaC26 a7b1ae7c7d Format setup harness menu changes 2026-06-26 05:27:06 +00:00
SabhyaC26 111f397741 feat(setup): group extra harnesses behind More
Keep the 0.3-supported harnesses prominent in setup while preserving access to the less-supported harnesses through an expanded menu.
2026-06-26 05:27:06 +00:00
3 changed files with 610 additions and 446 deletions
+259 -330
View File
@@ -9156,61 +9156,6 @@ def _credential_label(name: str, entry: ProviderEntry) -> str:
)
def _harness_summary_lines(config: dict[str, Any], family: str) -> list[str]: # type: ignore[explicit-any]
"""The styled sub-line(s) shown under a harness on the level-1 overview.
Returns a prominent default line a bold-green ```` + the default
credential's label, with the model dimmed — and, when there are other
credentials, a dim ``+N more`` line (the full list is one keystroke away on
level 2). Mirrors how ``gh`` / ``gcloud`` summaries surface the active
item: highlight it, don't enumerate the rest. The returned strings carry
Rich markup; :func:`_render_menu` indents them without re-styling.
:param config: The parsed config mapping (``providers:`` block).
:param family: The harness surface, ``"anthropic"``, ``"openai"``, or
``"pi"``.
:returns: One or two markup sub-lines, e.g. ``["[bold green]✓ Anthropic API
Key[/][dim] · claude-opus-4-8[/]", "[dim]+1 more[/]"]``, or
``["[dim]no credential yet — open to add one[/]"]``.
"""
from omnigent.onboarding.provider_config import (
load_providers,
provider_families,
surface_default_model,
surface_default_provider,
)
serving = [
(name, entry)
for name, entry in load_providers(config).items()
if family in provider_families(entry)
]
if not serving:
return ["[dim]no credential yet — open to add one[/]"]
# The surface's *effective* default: for the family surfaces this is the
# explicit per-family default; for pi it is what the pi harness would
# actually route through (explicit pi scope, else the fallback).
default = surface_default_provider(config, family)
default_label: str | None = None
default_model: str | None = None
others = 0
for name, entry in serving:
if default is not None and name == default.name:
default_label = _family_credential_label(config, family, name, entry)
default_model = surface_default_model(entry, family)
else:
others += 1
if default_label is None:
return ["[dim]no default set — open to choose one[/]"]
default_line = f"[bold green]✓ {default_label}[/]" + (
f"[dim] · {default_model}[/]" if default_model else ""
)
lines = [default_line]
if others:
lines.append(f"[dim]+{others} more[/]")
return lines
def _harness_credential_rows(config: dict[str, Any], family: str) -> list[_HarnessMenuRow]: # type: ignore[explicit-any]
"""Build the level-2 rows: each credential serving *family*, then ``+ Add``.
@@ -10888,13 +10833,21 @@ def _run_configure_harnesses_interactive() -> None:
Opening it backfills a legacy databricks ``auth:`` block into a real
provider and adopts any ambient-detected credential announcing the
newly auto-configured machine credentials in a callout then loops on
the level-1 harness overview (Claude / Codex / Pi / Cursor / Antigravity /
Qwen Code / Kimi Code / Quit) until the user quits or presses Esc.
the level-1 harness overview. Every harness is shown on a single compact
row the harness name on the left, then an aligned ````/```` status
column (the configured credential, or "Not installed" / "No
credential") — in 0.3 priority order: Claude, Codex, Cursor, OpenCode,
Hermes, Pi, then Antigravity, Qwen Code, Goose, Copilot, Kiro, Kimi Code.
The actionable hint (install command / next step) renders only for the
highlighted row, as the selector's description line, so the overview stays
uncluttered.
:returns: None. Side effect: may write ``~/.omnigent/config.yaml`` via
the backfill/adopt steps and any add/set-default/remove the user
performs while navigating.
"""
from rich.markup import escape
from omnigent.onboarding.antigravity_auth import (
ANTIGRAVITY_ENV_VARS,
ANTIGRAVITY_EXTRA_INSTALL_COMMAND,
@@ -10942,18 +10895,12 @@ def _run_configure_harnesses_interactive() -> None:
# Backfill a databricks provider from a legacy global auth: block FIRST (it
# outranks ambient detection in routing), then adopt ambient detections.
# The databricks backfill is silent (it just shows up in the harness summary
# The databricks backfill is silent (it just shows up in the harness status
# line); newly-adopted machine credentials get a one-time callout naming
# what was auto-configured and from where. The detection scan can take a
# beat (on macOS it shells out to ``claude auth status`` to read the
# Keychain), so surface a spinner over just that step — it clears before the
# callout (and the menu) paints, and is a no-op off a TTY.
from omnigent._runner_startup import runner_startup_progress
with runner_startup_progress(
initial_message="Searching for existing credentials…"
) as progress:
_adopt_ambient_credentials(progress=progress)
# what was auto-configured and from where. No progress spinner here: a
# transient spinner over the (fast) detection left a cleared-region gap and
# a residual line directly above the menu on first paint.
_adopt_ambient_credentials()
# Level 1: pick a harness. The cursor moves between Claude, Codex, Pi, and
# Quit; each harness's status renders as a non-selectable sub-line beneath
@@ -10991,299 +10938,281 @@ def _run_configure_harnesses_interactive() -> None:
# own drill-in rather than ``_manage_harness_providers``.
_KIMI = "\x00kimi"
families = [ANTHROPIC_FAMILY, OPENAI_FAMILY, PI_SURFACE]
while True:
config = _load_global_config()
options: list[str] = []
selectable: list[bool] = []
row_target: list[str | None] = []
for fam in families:
# A harness's readiness is a single descent: is the CLI installed? →
# does it have a usable default credential? → show that credential.
# Only a fully ready harness carries no name-level marker (its green
# default line in the summary already says it's ready); any harness
# that can't be used yet — not installed, or installed but with no
# usable default — gets a red ✗, so it's clear at a glance which
# harnesses still need attention. Pi's default is its *effective*
# one (explicit pi scope, else the cross-family fallback).
installed = harness_cli_installed(fam)
ready = installed and surface_default_provider(config, fam) is not None
marker = " " if ready else "[red]✗[/] "
options.append(f"{marker}{family_label(fam)}")
selectable.append(True)
row_target.append(fam)
# Sub-line text follows the same descent. An uninstalled harness
# points at the install command (creds are moot until it exists);
# otherwise the summary helper renders "no credential yet" / "no
# default set" / the ✓ default line.
if not installed:
# Parallel to "no credential yet — open to add one": name the
# state, point at the action. The exact ``npm install`` command
# is shown on drill-in (``_prompt_install_harness``), so it stays
# off the overview — keeping the line short enough not to wrap.
sub_lines = ["[dim]not installed yet — open to install[/]"]
else:
sub_lines = _harness_summary_lines(config, fam)
for sub_line in sub_lines:
# Indent every status sub-line a touch more than the harness
# name so it reads as hanging off the marker column — the
# configured default's ✓ (and the "not installed" / "no
# credential yet" hints) all start at the same column.
options.append(f" {sub_line}")
selectable.append(False) # a sub-line — cursor skips it
row_target.append(None)
# Cursor: runs via the ``cursor-sdk`` package and authenticates with a
# ``CURSOR_API_KEY`` (the SDK requires one; it has no provider/gateway
# family and a ``cursor-agent login`` does not apply). So readiness is
# simply whether an API key is configured — one stored by setup (the
# ``cursor:`` block) or inherited from the environment — and its
# drill-in manages exactly that key.
cursor_key_set = cursor_api_key_configured(config) or bool(
os.environ.get("CURSOR_API_KEY")
)
options.append(f"{' ' if cursor_key_set else '[red]✗[/] '}Cursor")
selectable.append(True)
row_target.append(CURSOR_KEY)
# ``cursor-sdk`` now ships in an OPTIONAL extra, so the key can be set
# with no SDK present. When the extra is missing, lead with that gap and
# the install command (parallel to Antigravity post-#322), then still
# report key status. ``[cursor]`` is escaped — sub-lines render through
# Rich markup, where bare brackets parse as a tag.
cursor_sub_lines: list[str] = []
if not cursor_sdk_installed():
from rich.markup import escape as _rich_escape
cursor_sub_lines.append(
f"[dim]not installed — open to install "
f"({_rich_escape(CURSOR_EXTRA_INSTALL_COMMAND)})[/]"
# Status glyph + Rich color per readiness kind: "ready" is a configured,
# launchable harness (green ✓); "missing" is an absent CLI/SDK (red ✗);
# "warn" is installed-but-unconfigured (yellow ✗ — present, not usable
# yet). The glyph leads the status, which sits in a left-aligned column
# right of the names, so every ✓/✗ lines up in a single column.
status_styles = {"ready": ("", "green"), "missing": ("", "red"), "warn": ("", "yellow")}
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
# parsing as Rich markup.
return f"Install with `{escape(command)}`"
def _family_row(fam: str) -> tuple[str, str, str, str, str]:
# Claude / Codex / Pi: a CLI binary plus a usable default credential.
# Pi's default is its *effective* one (explicit pi scope, else the
# cross-family fallback).
name = family_label(fam)
if not harness_cli_installed(fam):
return (
fam,
name,
"Not installed",
"missing",
_install_hint(" ".join(harness_install_command(fam))),
)
cursor_sub_lines.append(
"[green]✓[/] API key configured"
if cursor_key_set
else "[dim]no API key yet — open to add one[/]"
)
for cursor_sub in cursor_sub_lines:
options.append(f" {cursor_sub}")
selectable.append(False)
row_target.append(None)
# Antigravity (Gemini-native, no provider family): like Cursor, readiness
# is just whether a Gemini key is configured (``antigravity:`` block or
# ambient env); its drill-in manages that key. Vertex specs need no key,
# so a ✗ isn't a hard blocker for that path.
ag_key_set = antigravity_api_key_configured(config) or any(
os.environ.get(v) for v in ANTIGRAVITY_ENV_VARS
)
options.append(f"{' ' if ag_key_set else '[red]✗[/] '}Antigravity")
selectable.append(True)
row_target.append(_ANTIGRAVITY)
# The antigravity SDK ships in an OPTIONAL extra (unlike Cursor's baseline
# ``cursor-sdk``), so a user can have a key but no SDK. Lead with that gap when
# the extra is missing — naming the install command inline — then still report
# key status. ``[antigravity]`` is escaped since the sub-lines render as Rich
# markup (bare brackets parse as a tag).
ag_sub_lines: list[str] = []
if not antigravity_sdk_installed():
from rich.markup import escape as _rich_escape
default = surface_default_provider(config, fam)
if default is None:
return (fam, name, "Not configured", "warn", "Open to add a credential.")
label = _family_credential_label(config, fam, default.name, default)
return (fam, name, label, "ready", "")
ag_sub_lines.append(
f"[dim]not installed — open to install "
f"({_rich_escape(ANTIGRAVITY_EXTRA_INSTALL_COMMAND)})[/]"
)
ag_sub_lines.append(
"[green]✓[/] Gemini API key configured"
if ag_key_set
else "[dim]no Gemini API key yet — open to add one[/]"
)
for ag_sub in ag_sub_lines:
options.append(f" {ag_sub}")
selectable.append(False)
row_target.append(None)
# Qwen Code (OpenAI-compatible auth, no provider family — like Cursor /
# Antigravity). Qwen has no CLI login (its ``auth`` subcommand was
# removed); auth comes from OpenAI-compatible env vars or the interactive
# ``/auth`` flow. "Ready" means the CLI is installed AND we can detect
# auth — ``_qwen_auth_configured`` reads env vars / ~/.qwen creds, so the
# overview never falsely shows "signed in" for a fresh, unauthed install.
qwen_installed = harness_cli_installed(QWEN_KEY)
qwen_authed = qwen_installed and _qwen_auth_configured()
options.append(f"{' ' if qwen_authed else '[red]✗[/] '}Qwen Code")
selectable.append(True)
row_target.append(_QWEN)
if not qwen_installed:
from rich.markup import escape as _rich_escape
qwen_cmd = _rich_escape(" ".join(harness_install_command(QWEN_KEY)))
qwen_sub = f"[dim]not installed — open to install ({qwen_cmd})[/]"
elif qwen_authed:
qwen_sub = "[green]✓[/] authentication detected"
else:
qwen_sub = "[dim]installed — open to set up auth (/auth or env vars)[/]"
options.append(f" {qwen_sub}")
selectable.append(False)
row_target.append(None)
# OpenCode (native-server harness): readiness is just whether the
# ``opencode`` CLI is installed — it has no Omnigent-stored credential,
# routing through the bound agent's Databricks gateway profile or
# ambient provider env. Its drill-in installs the CLI and explains that.
# OpenCode: ready = CLI installed AND a provider reachable (a stored
# ``opencode auth login`` credential or a provider env key). Drill-in
# manages its native login. (Gateway path uses the agent profile.)
def build_harness_rows() -> list[tuple[str, str, str, str, str]]:
# One visible row per harness, in 0.3 priority order. No folding — every
# harness shows at once. Each row is (target, name, status, kind, hint),
# where ``hint`` is the selection-only description (install command /
# next step), empty for a ready harness.
from omnigent.onboarding.opencode_auth import opencode_auth_summary
opencode_summary = opencode_auth_summary()
opencode_ready = opencode_summary.ready
options.append(f"{' ' if opencode_ready else '[red]✗[/] '}OpenCode")
selectable.append(True)
row_target.append(_OPENCODE)
if not opencode_summary.installed:
from rich.markup import escape as _rich_escape
rows: list[tuple[str, str, str, str, str]] = []
rows.append(_family_row(ANTHROPIC_FAMILY))
rows.append(_family_row(OPENAI_FAMILY))
opencode_cmd = _rich_escape(" ".join(harness_install_command(OPENCODE_KEY)))
opencode_sub = f"[dim]not installed — open to install ({opencode_cmd})[/]"
elif opencode_ready:
opencode_sub = f"[green]✓[/] {opencode_summary.describe()}"
else:
opencode_sub = "[dim]installed — open to sign in (opencode auth login)[/]"
options.append(f" {opencode_sub}")
selectable.append(False)
row_target.append(None)
# Goose (its own provider config — no provider family, like Cursor /
# Antigravity / Qwen). Goose owns its auth via ``goose configure``
# (keyring / ~/.config/goose/config.yaml); Omnigent stores no key, so
# "ready" means the CLI is installed AND a provider is configured
# (``goose_config_summary`` reads GOOSE_PROVIDER from env or the config
# file, so a fresh, unconfigured install never falsely shows as ready).
goose_installed = harness_cli_installed(GOOSE_KEY)
goose_summary = goose_config_summary() if goose_installed else None
goose_ready = goose_summary is not None and goose_summary.provider is not None
options.append(f"{' ' if goose_ready else '[red]✗[/] '}Goose")
selectable.append(True)
row_target.append(_GOOSE)
if not goose_installed:
from rich.markup import escape as _rich_escape
goose_spec = harness_install_spec(GOOSE_KEY)
goose_hint = _rich_escape(
goose_spec.install_hint
if goose_spec and goose_spec.install_hint
else "brew install block-goose-cli"
# Cursor — readiness is the CURSOR_API_KEY (the cursor-sdk extra is a
# soft dependency; the key is independently storable, so a missing SDK
# is surfaced as the install hint, not a hard block).
if cursor_api_key_configured(config) or bool(os.environ.get("CURSOR_API_KEY")):
rows.append((CURSOR_KEY, "Cursor", "API key", "ready", ""))
elif not cursor_sdk_installed():
rows.append(
(
CURSOR_KEY,
"Cursor",
"Not installed",
"missing",
_install_hint(CURSOR_EXTRA_INSTALL_COMMAND),
),
)
goose_sub = f"[dim]not installed — open to install ({goose_hint})[/]"
elif goose_ready:
assert goose_summary is not None
goose_model = f" · {goose_summary.model}" if goose_summary.model else ""
goose_sub = f"[green]✓[/] {goose_summary.provider}{goose_model} configured"
else:
goose_sub = "[dim]installed — open to run goose configure[/]"
options.append(f" {goose_sub}")
selectable.append(False)
row_target.append(None)
# Copilot (GitHub Copilot SDK, no provider family): like Cursor, readiness
# is just whether a GitHub token with Copilot access is configured (the
# ``copilot:`` block or an ambient ``COPILOT_GITHUB_TOKEN``/``GH_TOKEN``/
# ``GITHUB_TOKEN``); its drill-in manages that token.
copilot_token_set = copilot_github_token_configured(config) or any(
os.environ.get(v) for v in COPILOT_TOKEN_ENV_VARS
)
options.append(f"{' ' if copilot_token_set else '[red]✗[/] '}Copilot")
selectable.append(True)
row_target.append(COPILOT_KEY)
# ``github-copilot-sdk`` ships in an OPTIONAL extra, so the token can be
# set with no SDK present. When the extra is missing, lead with that gap
# and the install command (parallel to Cursor / Antigravity), then still
# report token status. ``[copilot]`` is escaped — sub-lines render through
# Rich markup, where bare brackets parse as a tag.
copilot_sub_lines: list[str] = []
if not copilot_sdk_installed():
from rich.markup import escape as _rich_escape
copilot_sub_lines.append(
f"[dim]not installed — open to install "
f"({_rich_escape(COPILOT_EXTRA_INSTALL_COMMAND)})[/]"
rows.append(
(
CURSOR_KEY,
"Cursor",
"Not configured",
"warn",
"Open to add the Cursor API key.",
),
)
copilot_sub_lines.append(
"[green]✓[/] GitHub token configured"
if copilot_token_set
else "[dim]no GitHub token yet — open to add one[/]"
)
for copilot_sub in copilot_sub_lines:
options.append(f" {copilot_sub}")
selectable.append(False)
row_target.append(None)
# Hermes Agent (its own provider config via ``hermes model``, installed
# via a curl installer from Nous Research — no npm package or Omnigent
# credential).
hermes_installed = harness_cli_installed(HERMES_KEY)
options.append(f"{' ' if hermes_installed else '[red]✗[/] '}Hermes")
selectable.append(True)
row_target.append(_HERMES)
if not hermes_installed:
from rich.markup import escape as _rich_escape
# OpenCode — its own provider auth (login or env keys); the status is
# what it can reach (e.g. "1 stored").
opencode = opencode_auth_summary()
if not opencode.installed:
rows.append(
(
_OPENCODE,
"OpenCode",
"Not installed",
"missing",
_install_hint(" ".join(harness_install_command(OPENCODE_KEY))),
),
)
elif opencode.ready:
rows.append((_OPENCODE, "OpenCode", opencode.describe(), "ready", ""))
else:
rows.append(
(
_OPENCODE,
"OpenCode",
"Not configured",
"warn",
"Open to sign in (opencode auth login).",
),
)
# Hermes — curl-installed, no Omnigent credential, so readiness is just
# the binary.
if harness_cli_installed(HERMES_KEY):
rows.append(
(
_HERMES,
"Hermes",
"Installed",
"ready",
"Open to configure with `hermes model`.",
),
)
else:
hermes_spec = harness_install_spec(HERMES_KEY)
hermes_hint = _rich_escape(
hermes_hint = (
hermes_spec.install_hint
if hermes_spec and hermes_spec.install_hint
else "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash"
)
hermes_sub = f"[dim]not installed — open to install ({hermes_hint})[/]"
else:
hermes_sub = "[green]✓[/] ready"
options.append(f" {hermes_sub}")
selectable.append(False)
row_target.append(None)
# Kiro — native kiro-cli TUI (own auth via `kiro-cli login`, installed via
# Kiro's curl installer — no npm package or Omnigent credential).
kiro_installed = harness_cli_installed(KIRO_KEY)
options.append(f"{' ' if kiro_installed else '[red]✗[/] '}Kiro")
selectable.append(True)
row_target.append(_KIRO)
if not kiro_installed:
from rich.markup import escape as _rich_escape
rows.append(
(_HERMES, "Hermes", "Not installed", "missing", _install_hint(hermes_hint)),
)
rows.append(_family_row(PI_SURFACE))
# Antigravity — Gemini key (antigravity-sdk extra is soft, like Cursor).
if antigravity_api_key_configured(config) or any(
os.environ.get(v) for v in ANTIGRAVITY_ENV_VARS
):
rows.append((_ANTIGRAVITY, "Antigravity", "Gemini API key", "ready", ""))
elif not antigravity_sdk_installed():
rows.append(
(
_ANTIGRAVITY,
"Antigravity",
"Not installed",
"missing",
_install_hint(ANTIGRAVITY_EXTRA_INSTALL_COMMAND),
),
)
else:
rows.append(
(
_ANTIGRAVITY,
"Antigravity",
"Not configured",
"warn",
"Open to add the Gemini API key.",
),
)
# Qwen Code — no CLI login; auth via OpenAI-compatible env vars or the
# interactive /auth flow.
if not harness_cli_installed(QWEN_KEY):
rows.append(
(
_QWEN,
"Qwen Code",
"Not installed",
"missing",
_install_hint(" ".join(harness_install_command(QWEN_KEY))),
),
)
elif _qwen_auth_configured():
rows.append((_QWEN, "Qwen Code", "Authenticated", "ready", ""))
else:
rows.append(
(
_QWEN,
"Qwen Code",
"Not configured",
"warn",
"Open to set up auth (/auth or env vars).",
),
)
# Goose — its own provider config via `goose configure`.
if not harness_cli_installed(GOOSE_KEY):
goose_spec = harness_install_spec(GOOSE_KEY)
goose_hint = (
goose_spec.install_hint
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)))
else:
goose_summary = goose_config_summary()
if goose_summary.provider:
rows.append((_GOOSE, "Goose", goose_summary.provider, "ready", ""))
else:
rows.append(
(_GOOSE, "Goose", "Not configured", "warn", "Open to run `goose configure`."),
)
# Copilot — GitHub token (github-copilot-sdk extra is soft).
if copilot_github_token_configured(config) or any(
os.environ.get(v) for v in COPILOT_TOKEN_ENV_VARS
):
rows.append((COPILOT_KEY, "Copilot", "GitHub token", "ready", ""))
elif not copilot_sdk_installed():
rows.append(
(
COPILOT_KEY,
"Copilot",
"Not installed",
"missing",
_install_hint(COPILOT_EXTRA_INSTALL_COMMAND),
),
)
else:
rows.append(
(
COPILOT_KEY,
"Copilot",
"Not configured",
"warn",
"Open to add the GitHub token.",
),
)
# Kiro — native CLI, own auth via `kiro-cli login`.
if harness_cli_installed(KIRO_KEY):
rows.append((_KIRO, "Kiro", "Installed", "ready", "Sign in with `kiro-cli login`."))
else:
kiro_spec = harness_install_spec(KIRO_KEY)
kiro_hint = _rich_escape(
kiro_hint = (
kiro_spec.install_hint
if kiro_spec and kiro_spec.install_hint
else "curl -fsSL https://cli.kiro.dev/install | bash"
)
kiro_sub = f"[dim]not installed — open to install ({kiro_hint})[/]"
else:
kiro_sub = "[green]✓[/] installed — sign in with `kiro-cli login`"
options.append(f" {kiro_sub}")
selectable.append(False)
row_target.append(None)
# Kimi Code (Moonshot AI's multi-provider CLI, no provider family — like
# Cursor / Antigravity / Qwen). Auth lives entirely in the kimi CLI and
# Omnigent stores no kimi credential, so "ready" is just whether the
# binary is installed; the drill-in runs install + ``kimi login``. Kimi
# has no status probe, so the overview can't claim "signed in" — it only
# distinguishes installed vs. not.
kimi_installed = harness_cli_installed(KIMI_KEY)
options.append(f"{' ' if kimi_installed else '[red]✗[/] '}Kimi Code")
selectable.append(True)
row_target.append(_KIMI)
if not kimi_installed:
from rich.markup import escape as _rich_escape
rows.append((_KIRO, "Kiro", "Not installed", "missing", _install_hint(kiro_hint)))
# Kimi is curl-installed (package=None), so use its install_hint —
# ``harness_install_command`` raises ValueError for non-npm specs.
_kimi_spec = harness_install_spec(KIMI_KEY)
kimi_hint = (_kimi_spec.install_hint if _kimi_spec else None) or "see Kimi Code docs"
kimi_cmd = _rich_escape(kimi_hint)
kimi_sub = f"[dim]not installed — open to install ({kimi_cmd})[/]"
# Kimi Code — native CLI, own auth via `kimi login`. Curl-installed
# (no npm package), so use its install_hint.
if harness_cli_installed(KIMI_KEY):
rows.append((_KIMI, "Kimi Code", "Installed", "ready", "Sign in with `kimi login`."))
else:
kimi_sub = "[dim]installed — open to sign in (kimi login)[/]"
options.append(f" {kimi_sub}")
selectable.append(False)
row_target.append(None)
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)))
return rows
# Cap the status text so one verbose row (e.g. an OpenCode summary listing
# several providers) can't run off a narrow terminal.
max_status_width = 30
while True:
config = _load_global_config()
harness_rows = build_harness_rows()
# Left-align the status into a single column a fixed gutter right of the
# names, so every ✓/✗ glyph lines up vertically (a ragged right-aligned
# status scattered the glyphs and read as messy). The name column is the
# widest harness name + a 4-space gutter; the status is escaped when
# interpolated into markup so a credential label containing a ``[`` can't
# parse as a Rich tag (descriptions are escaped the same way).
name_col = max(len(name) for _t, name, *_rest in harness_rows) + 4
options: list[str] = []
selectable: list[bool] = []
row_target: list[str | None] = []
descriptions: list[str] = []
for target, name, status_text, kind, desc in harness_rows:
if len(status_text) > max_status_width:
status_text = status_text[: max_status_width - 1] + ""
glyph, color = status_styles[kind]
options.append(f"{name.ljust(name_col)}[{color}]{glyph} {escape(status_text)}[/]")
selectable.append(True)
row_target.append(target)
descriptions.append(desc)
options.append("Quit")
selectable.append(True)
row_target.append(_QUIT)
descriptions.append("")
idx = select(
"Configure harnesses",
options,
descriptions=descriptions,
selectable=selectable,
clear_on_exit=True,
compact=True,
)
if idx < 0: # Esc / q — exit
return
+21 -2
View File
@@ -83,6 +83,7 @@ def _render_menu(
status: str | None = None,
max_visible: int | None = None,
window_start: int = 0,
compact: bool = False,
) -> str:
"""Render the menu frame to an ANSI string for the termios redraw.
@@ -120,7 +121,10 @@ def _render_menu(
render_console.print(Text.from_markup(f" [bold green]{status}[/]"))
render_console.print()
render_console.print(Text.from_markup(f" [bold {ACCENT}]{title}[/]"))
render_console.print()
if not compact:
# The compact overview hugs the title to the list (Hermes-style); other
# menus keep a blank line below the title for breathing room.
render_console.print()
# Optional scrolling viewport: when *max_visible* is set and the list is
# longer, render only ``options[window_start : window_start + max_visible]``
@@ -174,7 +178,15 @@ def _render_menu(
render_console.print(Text.from_markup(f" [dim italic]{descriptions[selected]}[/]"))
render_console.print()
render_console.print(Text.from_markup(f" [{MUTED}]↑/↓ move · Enter select · Esc back[/]"))
# The compact overview is a top-level menu (Esc exits setup), so it shows a
# navigate/select/exit hint in the spirit of other modern CLIs; nested menus
# keep the "Esc back" wording, where Esc returns rather than exits.
hint = (
"↑/↓ navigate · Enter select · Esc to exit"
if compact
else "↑/↓ move · Enter select · Esc back"
)
render_console.print(Text.from_markup(f" [{MUTED}]{hint}[/]"))
return buf.getvalue()
@@ -301,6 +313,7 @@ def select(
clear_on_exit: bool = False,
status: str | None = None,
max_visible: int | None = None,
compact: bool = False,
) -> int:
"""Show a theme-picker-styled arrow-key menu and return the choice.
@@ -346,6 +359,11 @@ def select(
cursor (with "N more" markers) so a long flat list fits one screen
instead of overflowing and flickering. ``None`` renders every row.
No-op on the numbered fallback.
:param compact: When ``True`` (TTY only), render the dense top-level
overview layout: the title hugs the list (no blank line below it) and
the footer reads ``navigate · select · Esc to exit`` (Esc exits rather
than goes back). Intended for the setup harness overview. No-op on the
numbered fallback.
:returns: The chosen zero-based index into *options* (always a
selectable row), or ``-1`` when the user aborts — Esc / Ctrl-C /
Ctrl-D on the TTY, or ``q`` on the numbered fallback.
@@ -397,6 +415,7 @@ def select(
status=status,
max_visible=max_visible,
window_start=window_start[0],
compact=compact,
)
if prev_lines[0] > 0:
sys.stdout.write(f"\033[{prev_lines[0]}A")
+330 -114
View File
@@ -8,14 +8,15 @@ and the secret store (forced to the file backend via
shape, not just the command's exit code, so a regression in the
add/set-default/remove write paths surfaces here rather than silently.
``configure harnesses`` is a **three-level** picker. Level 1 picks a harness —
the cursor moves between ``1=Claude``, ``2=Codex``, ``3=Pi``, ``4=Cursor``,
``5=Antigravity``, and ``6=Quit`` (each
harness's prominent default + ``+N more`` summary renders as non-selectable
sub-lines that are skipped; a harness with no usable default — uninstalled or
unconfigured — shows a red ✗, while a configured one carries no name-level
marker). Selecting a
harness drills into level 2 — its configured credentials, then ``+ Add a
``configure harnesses`` is a **three-level** picker. Level 1 shows every
harness on a single compact row — the name on the left, then an aligned
``✓``/``✗`` status column — in 0.3 priority order: ``1=Claude``,
``2=Codex``, ``3=Cursor``, ``4=OpenCode``, ``5=Hermes``, ``6=Pi``,
``7=Antigravity``, ``8=Qwen Code``, ``9=Goose``, ``10=Copilot``, ``11=Kiro``,
``12=Kimi Code``, ``13=Quit``. There is no "More" folding — every harness is
visible at once — and the actionable hint (install command / next step)
renders only for the highlighted row, as the selector's description line.
Selecting a harness drills into level 2 — its configured credentials, then ``+ Add a
credential`` and ``← Back``. So an empty harness's level 2 is ``1=+Add 2=Back``;
with one credential it's ``1=<credential> 2=+Add 3=Back``. Selecting a
credential opens level 3 — ``Make default`` (only when not already the
@@ -1494,14 +1495,13 @@ def test_configure_harnesses_add_databricks_under_codex_scopes_to_codex(
def test_uninstalled_harness_shows_x_and_not_installed(isolated_config, monkeypatch) -> None:
"""A harness whose CLI isn't installed renders ✗ + a 'not installed yet' line.
"""A harness whose CLI isn't installed renders a red "Not installed" status.
Overrides the installed-by-default fixture. The level-1 overview must mark
the harness with ✗ (not ✓) and a sub-line flagging it as not installed —
the first rung of the installed? → credential? → show-credential descent.
The exact ``npm install`` command lives on drill-in
(``_prompt_install_harness``), not the overview, so the hint parallels "no
credential yet — open to add one" and stays short enough not to wrap.
Overrides the installed-by-default fixture. The level-1 overview folds the
readiness into the row's right-aligned status: an absent CLI reads
``✗ Not installed`` inline (the exact install command is the selection-only
description, surfaced when the row is highlighted, not in the always-visible
row).
"""
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_cli_installed", lambda family: False
@@ -1510,7 +1510,7 @@ def test_uninstalled_harness_shows_x_and_not_installed(isolated_config, monkeypa
assert result.exit_code == 0, result.output
out = result.output
assert "" in out
assert "not installed yet — open to install" in out
assert "Not installed" in out
def test_overview_marks_unconfigured_with_x_and_configured_without_checkmark(
@@ -1520,10 +1520,10 @@ def test_overview_marks_unconfigured_with_x_and_configured_without_checkmark(
Seeds only an Anthropic (Claude) default and leaves Codex with no
credential. The overview must (1) drop the old green ✓ next to the
configured Claude name — the green ✓ now lives only on the summary default
line — and (2) mark the unconfigured Codex with a red ✗ plus the "no
credential yet" hint. A regression that restores the name-level ✓ or fails
to flag the empty harness surfaces here.
configured Claude name — the green ✓ now lives only on the status column —
and (2) mark the installed-but-unconfigured Codex with a "Not configured"
status. A regression that restores the name-level ✓ or fails to flag the
empty harness surfaces here.
"""
config_path = os.path.join(isolated_config, "config.yaml")
with open(config_path, "w") as f:
@@ -1547,41 +1547,210 @@ def test_overview_marks_unconfigured_with_x_and_configured_without_checkmark(
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input="q\n")
assert result.exit_code == 0, result.output
out = result.output
# Configured Claude: NO name-level checkmark (the old "✓ Claude" is gone)…
# Configured Claude: the green ✓ rides the right-aligned status, not the
# name — so "✓ Claude" never appears, but the credential's "✓ …" does.
assert "✓ Claude" not in out
# …but the summary default line still carries its green ✓ on the credential.
assert "✓ Anthropic API Key" in out
# Unconfigured Codex: red ✗ on the name + the "no credential yet" hint.
assert "✗ Codex" in out
assert "no credential yet" in out
# Installed-but-unconfigured Codex: the row's status is a ✗ "Not configured".
assert "Codex" in out
assert "✗ Not configured" in out
def test_overview_lists_kiro_native_row(isolated_config, monkeypatch) -> None:
"""Level 1: Kiro appears as an installable native harness row.
def _capture_setup_overview(
monkeypatch,
) -> tuple[list[str], list[bool], list[str], bool]:
"""Render the level-1 setup overview once and capture the menu it builds.
Monkeypatches the shared ``select`` so the picker records the rows it would
draw, then returns ``-1`` (Esc) so setup exits after one frame. Returns the
captured ``(options, selectable, descriptions, compact)`` — enough to assert
the row set, ordering, single-line compactness, and the selection-only
install hints without driving a real TTY.
"""
captured: dict[str, object] = {}
def _capture_select(
title: str,
options: list[str],
*,
selectable: list[bool] | None = None,
descriptions: list[str] | None = None,
compact: bool = False,
clear_on_exit: bool = False,
**_kwargs: object,
) -> int:
assert title == "Configure harnesses"
assert selectable is not None
assert descriptions is not None
captured.update(
options=options,
selectable=selectable,
descriptions=descriptions,
compact=compact,
)
return -1
monkeypatch.setattr("omnigent.onboarding.interactive.select", _capture_select)
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"])
assert result.exit_code == 0, result.output
return (
captured["options"], # type: ignore[return-value]
captured["selectable"], # type: ignore[return-value]
captured["descriptions"], # type: ignore[return-value]
captured["compact"], # type: ignore[return-value]
)
def _overview_row_names(options: list[str], selectable: list[bool]) -> list[str]:
"""Extract the harness / Quit names from a captured overview frame.
Each row label is ``"<name><padding>[<color>]<glyph> <status>[/]"``; the
name is the text before the 2-space status gutter, recovered after Rich
markup is stripped.
"""
import re
from rich.text import Text
names: list[str] = []
for option, is_selectable in zip(options, selectable, strict=True):
if not is_selectable:
continue
plain = Text.from_markup(option).plain
names.append(re.split(r"\s{2,}", plain, maxsplit=1)[0].strip())
return names
def test_overview_lists_all_harnesses_in_priority_order(isolated_config, monkeypatch) -> None:
"""The overview shows every harness on one compact row, in 0.3 priority order.
No "More" folding: all twelve harnesses are visible at once, followed by
Quit. A regression that hides a harness, reorders the core six, or
reintroduces a collapse row fails here. The menu also opts into the compact
(underline-highlight) rendering.
"""
options, selectable, _descriptions, compact = _capture_setup_overview(monkeypatch)
assert _overview_row_names(options, selectable) == [
"Claude",
"Codex",
"Cursor",
"OpenCode",
"Hermes",
"Pi",
"Antigravity",
"Qwen Code",
"Goose",
"Copilot",
"Kiro",
"Kimi Code",
"Quit",
]
assert compact is True
def test_overview_rows_are_single_line(isolated_config, monkeypatch) -> None:
"""Every overview row is a single selectable line — no skipped sub-lines.
The compact layout folds each harness's status into its row (right-aligned)
instead of a dim sub-line beneath it, so the cursor lands on every rendered
row and each row carries a (possibly empty) description. A regression that
brings back non-selectable sub-lines fails here.
"""
options, selectable, descriptions, _compact = _capture_setup_overview(monkeypatch)
assert all(selectable)
assert len(descriptions) == len(options)
def test_overview_lists_kiro_row(isolated_config, monkeypatch) -> None:
"""Kiro is a first-class harness row with its own status + selection hint.
Kiro (``kiro-native``) is a native CLI harness with its own auth
(``kiro-cli login``), so — like Goose/Hermes — it belongs in the setup
overview. Absent the CLI it renders a red ✗ plus its curl install hint;
installed, it renders ready with the sign-in reminder. A regression that
drops the Kiro row (the gap this PR closes) fails here.
(``kiro-cli login``). Absent the CLI the row reads ``✗ Not installed`` and
its selection-only description names the curl installer; installed, it reads
``✓ Installed`` with the sign-in reminder. A regression that drops the Kiro
row fails here.
"""
# CLI absent → Kiro row + the curl install hint. (The red ✗ marker carries an
# ANSI reset between the glyph and the name, so assert on the stable text.)
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_cli_installed",
lambda family: False,
)
out = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input="q\n").output
assert "Kiro" in out
assert "cli.kiro.dev/install" in out
from rich.text import Text
# CLI present → ready row naming the sign-in step (no Omnigent credential).
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_cli_installed",
lambda family: True,
"omnigent.onboarding.harness_install.harness_cli_installed", lambda family: False
)
out = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input="q\n").output
assert "kiro-cli login" in out
options, selectable, descriptions, _ = _capture_setup_overview(monkeypatch)
names = _overview_row_names(options, selectable)
kiro = names.index("Kiro")
assert "Not installed" in Text.from_markup(options[kiro]).plain
assert "cli.kiro.dev/install" in Text.from_markup(descriptions[kiro]).plain
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_cli_installed", lambda family: True
)
options, selectable, descriptions, _ = _capture_setup_overview(monkeypatch)
names = _overview_row_names(options, selectable)
kiro = names.index("Kiro")
assert "Installed" in Text.from_markup(options[kiro]).plain
assert "kiro-cli login" in Text.from_markup(descriptions[kiro]).plain
@pytest.mark.parametrize(
"choice,manager_attr",
[
("4", "_manage_opencode_harness"),
("5", "_manage_hermes_harness"),
("8", "_manage_qwen_harness"),
("9", "_manage_goose_harness"),
("10", "_manage_copilot_harness"),
("11", "_manage_kiro_harness"),
("12", "_manage_kimi_harness"),
],
)
def test_overview_dispatches_to_correct_manager(
isolated_config, monkeypatch, choice: str, manager_attr: str
) -> None:
"""Selecting a harness routes to its drill-in, pinning position→sentinel→manager.
The ordering test asserts row *names* only, so a copy-paste slip that paired
the wrong sentinel with a name (e.g. ``(_QWEN, "Goose", …)``) would route
"Goose" to ``_manage_qwen_harness`` yet still pass the name check. This drives
the real numbered-fallback dispatch end-to-end for the seven harnesses whose
positions no other scripted-stdin test exercises (Claude/Codex/Cursor/Pi/
Antigravity are covered by the add/remove/key tests), so a misrouted row is
caught here.
"""
called: list[str] = []
monkeypatch.setattr(
f"omnigent.cli.{manager_attr}", lambda *a, **k: called.append(manager_attr)
)
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=f"{choice}\nq\n")
assert result.exit_code == 0, result.output
assert called == [manager_attr]
def test_overview_status_color_distinguishes_missing_from_unconfigured(
isolated_config, monkeypatch
) -> None:
"""The ✗ status color encodes *absent* (red) vs *installed-but-unconfigured* (yellow).
The kind taxonomy (``missing`` → red, ``warn`` → yellow, ``ready`` → green)
is the whole point of the status column, but the other overview tests assert
only the glyph + text. Here we capture the raw row markup (pre-render) and
pin the color so a regression that, say, paints an absent CLI yellow (telling
a user a missing tool is merely "unconfigured") fails.
"""
# Installed but unconfigured → yellow ✗ (a usable harness awaiting setup).
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_cli_installed", lambda family: True
)
options, selectable, _descriptions, _compact = _capture_setup_overview(monkeypatch)
codex = options[_overview_row_names(options, selectable).index("Codex")]
assert "[yellow]✗ Not configured[/]" in codex
# CLI absent → red ✗ (nothing to use yet).
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_cli_installed", lambda family: False
)
options, selectable, _descriptions, _compact = _capture_setup_overview(monkeypatch)
codex = options[_overview_row_names(options, selectable).index("Codex")]
assert "[red]✗ Not installed[/]" in codex
def test_drill_into_uninstalled_installs_then_proceeds(isolated_config, monkeypatch) -> None:
@@ -1690,9 +1859,9 @@ def test_configure_harnesses_pi_page_sets_explicit_pi_default(isolated_config) -
f,
)
# L1 3=Pi → L2 (1=anthropic 2=openai 3=+Add): select openai (2) → L3
# L1 6=Pi → L2 (1=anthropic 2=openai 3=+Add): select openai (2) → L3
# 1=Make default for Pi → back to L2 q=back → L1 q=exit.
stdin = "\n".join(["3", "2", "1", "q", "q"]) + "\n"
stdin = "\n".join(["6", "2", "1", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
@@ -1742,9 +1911,9 @@ def test_configure_harnesses_pi_page_excludes_subscription_rows(isolated_config)
f,
)
# L1 3=Pi → L2 renders its rows → q=back → L1 q=exit. The L2 frame is
# L1 6=Pi → L2 renders its rows → q=back → L1 q=exit. The L2 frame is
# cleared on exit under a TTY but the numbered fallback echoes options.
stdin = "\n".join(["3", "q", "q"]) + "\n"
stdin = "\n".join(["6", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
@@ -1790,8 +1959,8 @@ def test_configure_harnesses_add_databricks_under_pi_scopes_to_pi(
# Databricks position within the Pi add menu, computed live.
pi_opts = add_menu_options_for_family(PI_SURFACE)
db = next(i for i, o in enumerate(pi_opts) if o.kind == DATABRICKS_KIND) + 1
# L1 3=Pi → L2 1=+Add → add menu <db>=Databricks → URL → q → q.
stdin = "\n".join(["3", "1", str(db), "https://example.cloud.databricks.com", "q", "q"]) + "\n"
# L1 6=Pi → L2 1=+Add → add menu <db>=Databricks → URL → q → q.
stdin = "\n".join(["6", "1", str(db), "https://example.cloud.databricks.com", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
@@ -2041,9 +2210,9 @@ def test_cursor_set_api_key_paste_writes_block_and_secret(
Proves the api-key path: the secret lands in the store (never plaintext in
config) and the config references it via ``keychain:cursor``.
"""
# L1 4=Cursor → cursor menu 1=Set API key → paste key (crsr_ → no warn) →
# L1 3=Cursor → cursor menu 1=Set API key → paste key (crsr_ → no warn) →
# cursor menu q=back → L1 q=quit.
stdin = "\n".join(["4", "1", "crsr_test_key_123", "q", "q"]) + "\n"
stdin = "\n".join(["3", "1", "crsr_test_key_123", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
@@ -2063,9 +2232,9 @@ def test_cursor_adopt_env_api_key_writes_env_ref(
at the live environment variable so the key never leaves the user's shell.
"""
monkeypatch.setenv("CURSOR_API_KEY", "crsr_env_key_456")
# L1 4=Cursor → 1=Set API key → "y" adopt detected $CURSOR_API_KEY →
# L1 3=Cursor → 1=Set API key → "y" adopt detected $CURSOR_API_KEY →
# q back → q quit.
stdin = "\n".join(["4", "1", "y", "q", "q"]) + "\n"
stdin = "\n".join(["3", "1", "y", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
@@ -2084,9 +2253,9 @@ def test_cursor_remove_api_key_drops_block_and_secret(
with open(config_path, "w") as f:
yaml.safe_dump({"cursor": {"api_key_ref": "keychain:cursor"}}, f)
# L1 4=Cursor → cursor menu (key set: 1=Replace 2=Remove 3=Back) → 2=Remove
# L1 3=Cursor → cursor menu (key set: 1=Replace 2=Remove 3=Back) → 2=Remove
# → q back → q quit.
stdin = "\n".join(["4", "2", "q", "q"]) + "\n"
stdin = "\n".join(["3", "2", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
@@ -2103,9 +2272,9 @@ def test_cursor_set_api_key_non_crsr_declined_is_not_stored(
The soft prefix check warns and asks to store anyway; declining must leave
both the secret store and the config untouched.
"""
# L1 4=Cursor → 1=Set API key → paste non-crsr_ key → "n" decline warning →
# L1 3=Cursor → 1=Set API key → paste non-crsr_ key → "n" decline warning →
# q back → q quit.
stdin = "\n".join(["4", "1", "sk-not-a-cursor-key", "n", "q", "q"]) + "\n"
stdin = "\n".join(["3", "1", "sk-not-a-cursor-key", "n", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
@@ -2135,20 +2304,24 @@ def _cursor_sdk_absent(monkeypatch):
)
def test_cursor_overview_surfaces_install_command_when_sdk_missing(
isolated_config, _cursor_sdk_absent
def test_cursor_overview_install_command_is_selection_only(
isolated_config, _cursor_sdk_absent, monkeypatch
) -> None:
"""L1 overview: the Cursor row names the extra install command when absent.
"""With the cursor extra absent, the Cursor row's install command is its description.
The exact ``pip install "omnigent[cursor]"`` is shown (escaped so the
literal brackets render).
The exact ``pip install "omnigent[cursor]"`` (brackets included) is the
selection-only hint — the selector's per-row description, shown when the row
is highlighted — and is NOT baked into the always-visible row label. This is
the "tooltip only on selection" behavior.
"""
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input="q\n")
assert result.exit_code == 0, result.output
out = result.output
assert "not installed — open to install" in out
# The literal command (brackets included) reaches the rendered output.
assert 'pip install "omnigent[cursor]"' in out
from rich.text import Text
options, selectable, descriptions, _ = _capture_setup_overview(monkeypatch)
names = _overview_row_names(options, selectable)
cursor = names.index("Cursor")
assert 'pip install "omnigent[cursor]"' in Text.from_markup(descriptions[cursor]).plain
# The command lives in the description only — never the always-visible row.
assert "pip install" not in Text.from_markup(options[cursor]).plain
def test_cursor_drillin_offers_install_when_sdk_missing(
@@ -2159,8 +2332,8 @@ def test_cursor_drillin_offers_install_when_sdk_missing(
Here the user picks "show the command" (choice 3), which prints it and falls
through to the key menu, then backs out.
"""
# L1 4=Cursor → install offer 3=show command → key menu q=back → L1 q.
stdin = "\n".join(["4", "3", "q", "q"]) + "\n"
# L1 3=Cursor → install offer 3=show command → key menu q=back → L1 q.
stdin = "\n".join(["3", "3", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
out = result.output
@@ -2175,9 +2348,9 @@ def test_cursor_key_settable_when_sdk_missing(isolated_config, _cursor_sdk_absen
NOT gate key management on it. Here the user declines ("set the key anyway" =
choice 2), then sets the key — which must persist as it does with the SDK.
"""
# L1 4=Cursor → install offer 2=set key anyway → key menu 1=Set →
# L1 3=Cursor → install offer 2=set key anyway → key menu 1=Set →
# paste crsr_ key → key menu q=back → L1 q=quit.
stdin = "\n".join(["4", "2", "1", "crsr_key_no_sdk", "q", "q"]) + "\n"
stdin = "\n".join(["3", "2", "1", "crsr_key_no_sdk", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
@@ -2205,8 +2378,8 @@ def test_cursor_install_now_invokes_runner_without_index(
monkeypatch.setattr("omnigent.onboarding.cursor_auth.shutil.which", lambda name: None)
monkeypatch.setattr("omnigent.onboarding.cursor_auth.subprocess.run", _run)
# L1 4=Cursor → install offer 1=install now → key menu q=back → L1 q.
stdin = "\n".join(["4", "1", "q", "q"]) + "\n"
# L1 3=Cursor → install offer 1=install now → key menu q=back → L1 q.
stdin = "\n".join(["3", "1", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
@@ -2219,10 +2392,10 @@ def test_cursor_install_now_invokes_runner_without_index(
# ── Antigravity Gemini API-key flow ─────────────────────────────────────────
# Antigravity (Gemini-native, no provider family) drills in at L1 row 5
# (Claude/Codex/Pi/Antigravity/Quit) and stores its key in the secret store +
# the ``antigravity:`` config block. API-key-only menu (Set/Replace/Remove);
# ``isolated_config`` clears ambient GEMINI_API_KEY / ANTIGRAVITY_API_KEY.
# Antigravity (Gemini-native, no provider family) is row 7 on the overview (it
# follows Pi) and stores its key in the secret store + the ``antigravity:``
# config block. API-key-only menu (Set/Replace/Remove); ``isolated_config``
# clears ambient GEMINI_API_KEY / ANTIGRAVITY_API_KEY.
@pytest.fixture()
@@ -2252,9 +2425,9 @@ def test_antigravity_set_api_key_paste_writes_block_and_secret(
Proves the api-key path: the secret lands in the store (never plaintext in
config) and the config references it via ``keychain:antigravity``.
"""
# L1 5=Antigravity → antigravity menu 1=Set API key → paste key (AIza → no
# warn) → antigravity menu q=back → L1 q=quit.
stdin = "\n".join(["5", "1", "AIza_test_key_123", "q", "q"]) + "\n"
# L1 7=Antigravity → antigravity menu 1=Set API key →
# paste key (AIza → no warn) → antigravity menu q=back → L1 q=quit.
stdin = "\n".join(["7", "1", "AIza_test_key_123", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
@@ -2274,9 +2447,9 @@ def test_antigravity_adopt_env_api_key_writes_env_ref(
at the live environment variable so the key never leaves the user's shell.
"""
monkeypatch.setenv("GEMINI_API_KEY", "AIza_env_key_456")
# L1 5=Antigravity → 1=Set API key → "y" adopt detected $GEMINI_API_KEY →
# q back → q quit.
stdin = "\n".join(["5", "1", "y", "q", "q"]) + "\n"
# L1 7=Antigravity → 1=Set API key →
# "y" adopt detected $GEMINI_API_KEY → q back → q quit.
stdin = "\n".join(["7", "1", "y", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
@@ -2295,9 +2468,9 @@ def test_antigravity_remove_api_key_drops_block_and_secret(
with open(config_path, "w") as f:
yaml.safe_dump({"antigravity": {"api_key_ref": "keychain:antigravity"}}, f)
# L1 5=Antigravity → antigravity menu (key set: 1=Replace 2=Remove 3=Back) →
# 2=Remove → q back → q quit.
stdin = "\n".join(["5", "2", "q", "q"]) + "\n"
# L1 7=Antigravity → antigravity menu (key set:
# 1=Replace 2=Remove 3=Back) → 2=Remove → q back → q quit.
stdin = "\n".join(["7", "2", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
@@ -2322,8 +2495,9 @@ def test_antigravity_remove_does_not_delete_foreign_keychain_secret(
with open(config_path, "w") as f:
yaml.safe_dump({"antigravity": {"api_key_ref": "keychain:shared-gemini"}}, f)
# L1 5=Antigravity → antigravity menu 2=Remove → q back → q quit.
stdin = "\n".join(["5", "2", "q", "q"]) + "\n"
# L1 7=Antigravity → antigravity menu 2=Remove →
# q back → q quit.
stdin = "\n".join(["7", "2", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
@@ -2341,9 +2515,9 @@ def test_antigravity_set_api_key_non_aiza_declined_is_not_stored(
The soft prefix check warns and asks to store anyway; declining must leave
both the secret store and the config untouched.
"""
# L1 5=Antigravity → 1=Set API key → paste non-AIza key → "n" decline
# warning → q back → q quit.
stdin = "\n".join(["5", "1", "sk-not-a-gemini-key", "n", "q", "q"]) + "\n"
# L1 7=Antigravity → 1=Set API key →
# paste non-AIza key → "n" decline warning → q back → q quit.
stdin = "\n".join(["7", "1", "sk-not-a-gemini-key", "n", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
@@ -2374,20 +2548,60 @@ def _antigravity_sdk_absent(monkeypatch):
)
def test_antigravity_overview_surfaces_install_command_when_sdk_missing(
isolated_config, _antigravity_sdk_absent
def test_antigravity_overview_install_command_is_selection_only(
isolated_config, _antigravity_sdk_absent, monkeypatch
) -> None:
"""L1 overview: the Antigravity row names the extra install command when absent.
"""With the antigravity extra absent, the Antigravity row's install command is its description.
The exact ``pip install "omnigent[antigravity]"`` is shown (escaped so the literal
brackets render). Without the SDK-detection branch this line never appears.
The exact ``pip install "omnigent[antigravity]"`` (brackets included) is the
selection-only hint — the selector's per-row description — not baked into the
always-visible row. Without the SDK-detection branch the hint never appears.
"""
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input="q\n")
assert result.exit_code == 0, result.output
out = result.output
assert "not installed — open to install" in out
# The literal command (brackets included) reaches the rendered output.
assert 'pip install "omnigent[antigravity]"' in out
from rich.text import Text
options, selectable, descriptions, _ = _capture_setup_overview(monkeypatch)
names = _overview_row_names(options, selectable)
antigravity = names.index("Antigravity")
assert (
'pip install "omnigent[antigravity]"' in Text.from_markup(descriptions[antigravity]).plain
)
assert "pip install" not in Text.from_markup(options[antigravity]).plain
@pytest.fixture()
def _copilot_sdk_absent(monkeypatch):
"""Force the ``github-copilot-sdk`` extra absent and clear ambient Copilot tokens.
Copilot is the third soft-SDK-extra harness (like Cursor / Antigravity): its
readiness is a GitHub token, and a missing SDK is surfaced as an install
hint rather than a hard block. This drives the unconfigured + SDK-absent
state so the overview row reads "Not installed" with the extra's install
command as its selection-only description.
:param monkeypatch: Pytest monkeypatch fixture.
"""
for var in ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"):
monkeypatch.delenv(var, raising=False)
monkeypatch.setattr("omnigent.onboarding.copilot_auth.copilot_sdk_installed", lambda: False)
def test_copilot_overview_install_command_is_selection_only(
isolated_config, _copilot_sdk_absent, monkeypatch
) -> None:
"""With the copilot extra absent, the Copilot row's install command is its description.
Mirrors the Cursor / Antigravity selection-only-hint contract for the third
soft-SDK-extra harness: the ``omnigent[copilot]`` install command is the
per-row description (shown only when highlighted), never baked into the
always-visible row label.
"""
from rich.text import Text
options, selectable, descriptions, _ = _capture_setup_overview(monkeypatch)
names = _overview_row_names(options, selectable)
copilot = names.index("Copilot")
assert "omnigent[copilot]" in Text.from_markup(descriptions[copilot]).plain
assert "pip install" not in Text.from_markup(options[copilot]).plain
def test_antigravity_drillin_offers_install_when_sdk_missing(
@@ -2398,8 +2612,9 @@ def test_antigravity_drillin_offers_install_when_sdk_missing(
The user picks "show the command" (choice 3), which prints the command and falls
through to the key menu, then backs out.
"""
# L1 5=Antigravity → install offer 3=show command → key menu q=back → L1 q.
stdin = "\n".join(["5", "3", "q", "q"]) + "\n"
# L1 7=Antigravity → install offer 3=show command →
# key menu q=back → L1 q.
stdin = "\n".join(["7", "3", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
out = result.output
@@ -2416,9 +2631,9 @@ def test_antigravity_key_settable_when_sdk_missing(
gate key management on it. The user declines ("set the key anyway" = choice 2),
then sets the key, which must persist as it does with the SDK present.
"""
# L1 5=Antigravity → install offer 2=set key anyway → key menu 1=Set →
# paste AIza key → key menu q=back → L1 q=quit.
stdin = "\n".join(["5", "2", "1", "AIza_key_no_sdk", "q", "q"]) + "\n"
# L1 7=Antigravity → install offer 2=set key anyway →
# key menu 1=Set → paste AIza key → key menu q=back → L1 q=quit.
stdin = "\n".join(["7", "2", "1", "AIza_key_no_sdk", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
@@ -2446,8 +2661,9 @@ def test_antigravity_install_now_invokes_runner_without_index(
monkeypatch.setattr("omnigent.onboarding.antigravity_auth.shutil.which", lambda name: None)
monkeypatch.setattr("omnigent.onboarding.antigravity_auth.subprocess.run", _run)
# L1 5=Antigravity → install offer 1=install now → key menu q=back → L1 q.
stdin = "\n".join(["5", "1", "q", "q"]) + "\n"
# L1 7=Antigravity → install offer 1=install now →
# key menu q=back → L1 q.
stdin = "\n".join(["7", "1", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
@@ -2501,8 +2717,8 @@ def test_configure_harnesses_add_other_key_no_remaining_providers_aborts_cleanly
monkeypatch.setattr("omnigent.onboarding.configure_models.other_key_providers", list)
other = _other_key_add_menu_index(PI_SURFACE)
# L1 3=Pi → L2 1=+Add → add menu <other>=Other provider — API key → L2 q=back → L1 q=exit.
stdin = "\n".join(["3", "1", str(other), "q", "q"]) + "\n"
# L1 6=Pi → L2 1=+Add → add menu <other>=Other provider — API key → L2 q=back → L1 q=exit.
stdin = "\n".join(["6", "1", str(other), "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
# Pre-fix this exited non-zero with a ValueError; the guard makes it graceful.