13078 lines
518 KiB
Python
13078 lines
518 KiB
Python
"""CLI entry point for omnigent."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import collections.abc
|
||
import contextlib
|
||
import copy
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import secrets
|
||
import shutil
|
||
import signal
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
import types
|
||
from collections.abc import Callable, Mapping, Sequence
|
||
from dataclasses import asdict, dataclass
|
||
from importlib import resources
|
||
from pathlib import Path
|
||
from typing import TYPE_CHECKING, Any, BinaryIO, TypeAlias, cast
|
||
|
||
import click
|
||
import yaml
|
||
from pydantic import BaseModel, ConfigDict
|
||
from rich import box
|
||
from rich.console import Console
|
||
from rich.table import Table
|
||
|
||
from omnigent._platform import IS_WINDOWS, resolve_repo_symlink
|
||
from omnigent._startup_profile import StartupProfiler
|
||
from omnigent.cli_sandbox import lakebox as _lakebox_alias_group
|
||
from omnigent.cli_sandbox import sandbox as _sandbox_group
|
||
from omnigent.config import (
|
||
global_config_path,
|
||
load_global_config,
|
||
load_local_config,
|
||
)
|
||
from omnigent.harness_aliases import canonicalize_harness
|
||
from omnigent.host.local_server import (
|
||
_DEFAULT_LOCAL_PORT,
|
||
_pid_alive,
|
||
ensure_local_omnigent_server,
|
||
local_server_status,
|
||
local_server_url_if_healthy,
|
||
server_config_signature,
|
||
stop_local_omnigent_server,
|
||
stop_untracked_local_server,
|
||
)
|
||
from omnigent.inner import _proc, ui
|
||
from omnigent.onboarding.sandboxes import available_providers as _sandbox_providers
|
||
from omnigent.onboarding.ucode_setup import (
|
||
build_ucode_configure_command,
|
||
find_ucode_command,
|
||
model_gateway_workspace_urls,
|
||
)
|
||
|
||
if TYPE_CHECKING:
|
||
import httpx
|
||
|
||
from omnigent._runner_startup import RunnerStartupProgress
|
||
from omnigent.onboarding.ambient import DetectedProvider
|
||
from omnigent.onboarding.provider_config import ProviderEntry
|
||
from omnigent.update_check import _InstalledWheelInfo
|
||
|
||
|
||
# Any: YAML configs have heterogeneous value types (str, int, list, etc.)
|
||
def _load_config(path: str | None) -> dict[str, Any]: # type: ignore[explicit-any]
|
||
"""
|
||
Load and return config from a YAML file.
|
||
Returns an empty dict if no path is provided.
|
||
"""
|
||
if path is None:
|
||
return {}
|
||
with open(path) as f:
|
||
return yaml.safe_load(f) or {}
|
||
|
||
|
||
def _server_uvicorn_log_config() -> dict[str, Any]: # type: ignore[explicit-any]
|
||
"""
|
||
Return Uvicorn logging config with request-duration access logs.
|
||
|
||
Uvicorn emits the FastAPI access line itself, so Omnigent swaps
|
||
only the access formatter while preserving Uvicorn's default
|
||
handlers, levels, and server-log formatting.
|
||
|
||
:returns: Uvicorn ``log_config`` suitable for ``uvicorn.run``.
|
||
"""
|
||
import uvicorn.config
|
||
|
||
log_config = copy.deepcopy(uvicorn.config.LOGGING_CONFIG)
|
||
log_config["formatters"]["access"]["()"] = (
|
||
"omnigent.server.performance_metrics.RequestDurationAccessFormatter"
|
||
)
|
||
return log_config
|
||
|
||
|
||
# Path to the user-level global config file, analogous to ~/.gitconfig.
|
||
# Tests may set ``OMNIGENT_CONFIG_HOME`` to isolate subprocesses from a
|
||
# developer's real ``~/.omnigent/config.yaml``.
|
||
_CONFIG_HOME_ENV_VAR = "OMNIGENT_CONFIG_HOME"
|
||
_GLOBAL_CONFIG_PATH: Path = Path.home() / ".omnigent" / "config.yaml"
|
||
|
||
# Per-user state directories before / after the omniagents -> omnigent rename.
|
||
# All per-user state (config, registered agents, auth tokens, the host daemon
|
||
# pidfile, runner identity, native session state, logs) lives under
|
||
# :data:`_STATE_DIR`; :func:`_migrate_legacy_state_dir` relocates the old
|
||
# directory on first run. ``OMNIGENT_DATA_DIR`` is the data-isolation override
|
||
# a worktree / test sets; when present the user manages their own state and
|
||
# migration is skipped.
|
||
_STATE_DIR: Path = Path.home() / ".omnigent"
|
||
# Pre-rename state directories, newest first. The name evolved
|
||
# ``~/.omniagents`` -> ``~/.omnigents`` -> ``~/.omnigent``; migrate from the
|
||
# newest legacy directory that still exists.
|
||
_LEGACY_STATE_DIRS: tuple[Path, ...] = (
|
||
Path.home() / ".omnigents",
|
||
Path.home() / ".omniagents",
|
||
)
|
||
_DATA_DIR_ENV_VAR = "OMNIGENT_DATA_DIR"
|
||
|
||
|
||
def _migrate_legacy_state_dir() -> None:
|
||
"""
|
||
One-time relocation of a pre-rename state directory to ``~/.omnigent``.
|
||
|
||
Earlier releases stored all per-user state under ``~/.omniagents`` and then
|
||
``~/.omnigents`` as the name evolved. To avoid silently losing that state,
|
||
move the newest surviving legacy directory to ``~/.omnigent`` on first run,
|
||
but only when **all** of the following hold:
|
||
|
||
- the new ``~/.omnigent`` does not yet exist (never clobber new state),
|
||
- at least one directory in :data:`_LEGACY_STATE_DIRS` exists,
|
||
- neither :data:`_CONFIG_HOME_ENV_VAR` nor :data:`_DATA_DIR_ENV_VAR` is set
|
||
(an operator who redirects state elsewhere manages it themselves), and
|
||
- no live host daemon is running out of that legacy directory -- moving its
|
||
pidfile / socket dir out from under a running daemon would wedge it.
|
||
|
||
On failure the migration is skipped with a warning rather than crashing the
|
||
CLI; a fresh ``~/.omnigent`` is then created normally and the legacy
|
||
directory is left untouched for the user to migrate by hand. Idempotent:
|
||
once ``~/.omnigent`` exists this is a no-op.
|
||
|
||
:returns: ``None``.
|
||
"""
|
||
if _STATE_DIR.exists():
|
||
return
|
||
if os.environ.get(_CONFIG_HOME_ENV_VAR) or os.environ.get(_DATA_DIR_ENV_VAR):
|
||
return
|
||
legacy_src = next((d for d in _LEGACY_STATE_DIRS if d.exists()), None)
|
||
if legacy_src is None:
|
||
return
|
||
|
||
# Guard: a daemon spawned by the old release may still be running with its
|
||
# pidfile + unix socket under the legacy dir. Relocating those would leave
|
||
# the daemon orphaned and the CLI unable to find it.
|
||
legacy_pid_file = legacy_src / "host.pid"
|
||
if legacy_pid_file.exists():
|
||
try:
|
||
first_line = legacy_pid_file.read_text().strip().splitlines()[0]
|
||
legacy_pid = int(first_line)
|
||
except (ValueError, OSError, IndexError):
|
||
legacy_pid = None
|
||
if legacy_pid is not None and _pid_alive(legacy_pid):
|
||
click.echo(
|
||
f"Note: found pre-rename state at {legacy_src} but a host daemon "
|
||
"is still running from it; skipping migration. Run `omnigent stop` "
|
||
"and re-run to migrate, or move it manually to ~/.omnigent.",
|
||
err=True,
|
||
)
|
||
return
|
||
|
||
try:
|
||
shutil.move(str(legacy_src), str(_STATE_DIR))
|
||
except OSError as exc:
|
||
click.echo(
|
||
f"Note: could not migrate {legacy_src} to ~/.omnigent ({exc}); "
|
||
f"starting with fresh state. Your old data is untouched at {legacy_src}.",
|
||
err=True,
|
||
)
|
||
return
|
||
click.echo(f"Migrated per-user state from {legacy_src} to ~/.omnigent.", err=True)
|
||
|
||
|
||
# Project-level config relative to cwd, analogous to .git/config.
|
||
# Resolved at call time so tests can control cwd.
|
||
_LOCAL_CONFIG_RELPATH: Path = Path(".omnigent") / "config.yaml"
|
||
|
||
# Keys that ``omnigent config`` accepts. Mirrors the option names in
|
||
# the ``run`` command so the mapping is explicit and auditable.
|
||
_AUTO_OPEN_CONVERSATION_CONFIG_KEY = "auto_open_conversation"
|
||
_GLOBAL_CONFIG_KEYS: frozenset[str] = frozenset(
|
||
{
|
||
"default_agent",
|
||
"harness",
|
||
"model",
|
||
# OpenCode-specific default model (``provider/model``) the native
|
||
# ``omni opencode`` TUI launches on; set via `omni setup` → OpenCode.
|
||
"opencode_model",
|
||
"server",
|
||
_AUTO_OPEN_CONVERSATION_CONFIG_KEY,
|
||
}
|
||
)
|
||
_BOOLEAN_CONFIG_KEYS: frozenset[str] = frozenset({_AUTO_OPEN_CONVERSATION_CONFIG_KEY})
|
||
_CONFIG_TRUE_VALUES: frozenset[str] = frozenset({"1", "true", "yes", "on"})
|
||
_CONFIG_FALSE_VALUES: frozenset[str] = frozenset({"0", "false", "no", "off"})
|
||
_ConfigValue: TypeAlias = (
|
||
str | int | float | bool | None | list["_ConfigValue"] | dict[str, "_ConfigValue"]
|
||
)
|
||
|
||
_GLOBAL_AGENTS_DIR: Path = Path.home() / ".omnigent" / "agents"
|
||
_INTERNAL_BETA_DEFAULT_AGENT_NAME: str = "databricks_coding_agent.yaml"
|
||
_INTERNAL_BETA_BUNDLED_AGENTS: tuple[str, ...] = (
|
||
"databricks_coding_agent.yaml",
|
||
"knowledge_work_agent.yaml",
|
||
)
|
||
# _INTERNAL_BETA_DEFAULT_SERVER (internal Databricks Apps host) moved to
|
||
# omnigent.onboarding.internal_beta (excluded from the OSS build); the
|
||
# internal-beta setup branch and the sandbox CLI import it from there.
|
||
_CLAUDE_STARTUP_PROFILE_ENV_VAR = "OMNIGENT_CLAUDE_STARTUP_PROFILE"
|
||
# Brand shown for an auto-configured CLI login in the credentials callout —
|
||
# the product the login authenticates, not the CLI name (the codex CLI logs in
|
||
# a ChatGPT subscription). Keyed by the ambient detection name; these are the
|
||
# only two subscription CLIs ambient detection emits.
|
||
_CLI_LOGIN_BRAND: dict[str, str] = {"claude": "Claude", "codex": "ChatGPT"}
|
||
_HOST_DAEMON_STOP_GRACE_S = 5.0
|
||
# How often ``omni upgrade`` re-polls the local server for in-flight
|
||
# (connected) sessions while draining before it stops the server.
|
||
_UPGRADE_DRAIN_POLL_S = 2.0
|
||
# When reusing an existing daemon, how long to let a live-but-offline daemon
|
||
# (re)establish its server tunnel before treating it as a zombie and
|
||
# respawning. Covers the daemon's reconnect backoff after a transient drop.
|
||
_DAEMON_RECONNECT_GRACE_S = 5.0
|
||
# Don't tear down a daemon younger than this for an offline tunnel: it may be
|
||
# a freshly-spawned daemon (possibly from a concurrent invocation) still
|
||
# bringing its tunnel up. Avoids racing/thrashing sibling invocations.
|
||
_DAEMON_REUSE_MIN_AGE_S = 6.0
|
||
|
||
# How long uvicorn waits for active connections (WebSocket, SSE) after
|
||
# SIGTERM before force-closing them. SSE streams signal themselves via
|
||
# session_stream.shutdown_all() in _ShutdownSignalingServer.shutdown(),
|
||
# so the main remaining consumers of this window are WebSocket tunnels
|
||
# that need a moment to drain. 5 s is enough for a clean tunnel teardown
|
||
# while keeping Ctrl-C feeling instant.
|
||
# Overridable via OMNIGENT_SERVER_SHUTDOWN_TIMEOUT_S for deployments that
|
||
# need a longer drain window (e.g. large file uploads).
|
||
_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S_DEFAULT = 5
|
||
_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S = int(
|
||
os.environ.get(
|
||
"OMNIGENT_SERVER_SHUTDOWN_TIMEOUT_S",
|
||
str(_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S_DEFAULT),
|
||
)
|
||
)
|
||
|
||
_LOCAL_DAEMON_ENV_ALLOWLIST: frozenset[str] = frozenset(
|
||
{
|
||
"ANTHROPIC_API_KEY",
|
||
"ANTHROPIC_AUTH_TOKEN",
|
||
"ANTHROPIC_BASE_URL",
|
||
"ANTHROPIC_BEDROCK_BASE_URL",
|
||
"AWS_BEARER_TOKEN_BEDROCK",
|
||
"CLAUDE_CODE_USE_BEDROCK",
|
||
"CLAUDE_CODE_SKIP_BEDROCK_AUTH",
|
||
"COHERE_API_KEY",
|
||
"DEEPSEEK_API_KEY",
|
||
"GEMINI_API_KEY",
|
||
"GOOGLE_API_KEY",
|
||
"GROQ_API_KEY",
|
||
"MISTRAL_API_KEY",
|
||
"OMNIGENT_DATABASE_URI",
|
||
"OPENAI_API_KEY",
|
||
"OPENAI_BASE_URL",
|
||
"OPENAI_ORG_ID",
|
||
"OPENAI_ORGANIZATION",
|
||
"OPENROUTER_API_KEY",
|
||
"PERPLEXITY_API_KEY",
|
||
"TOGETHER_API_KEY",
|
||
"VOYAGE_API_KEY",
|
||
"XAI_API_KEY",
|
||
}
|
||
)
|
||
_LOCAL_DAEMON_ENV_PREFIXES: tuple[str, ...] = (
|
||
"ANTHROPIC_DEFAULT_",
|
||
"AZURE_OPENAI_",
|
||
"DATABRICKS_",
|
||
"MLFLOW_",
|
||
"OTEL_",
|
||
"OMNIGENT_",
|
||
"OPENAI_",
|
||
)
|
||
_HostJsonValue: TypeAlias = (
|
||
str | int | float | bool | None | list["_HostJsonValue"] | dict[str, "_HostJsonValue"]
|
||
)
|
||
_HostJsonObject: TypeAlias = dict[str, _HostJsonValue]
|
||
_HostSessionRow: TypeAlias = dict[str, _HostJsonValue]
|
||
_HostPayload: TypeAlias = dict[str, _HostJsonValue]
|
||
|
||
|
||
def _effective_global_config_path() -> Path:
|
||
"""
|
||
Return the path to the user-level Omnigent config.
|
||
|
||
:returns: ``$OMNIGENT_CONFIG_HOME/config.yaml`` when the env
|
||
override is set, otherwise :data:`_GLOBAL_CONFIG_PATH`.
|
||
"""
|
||
return global_config_path(_GLOBAL_CONFIG_PATH)
|
||
|
||
|
||
def _display_path(path: Path) -> str:
|
||
"""
|
||
Format a filesystem path for display, collapsing the home prefix to ``~``.
|
||
|
||
A path under the user's home directory is shown as ``~/...`` for
|
||
readability; anything else is shown as its plain string. Unlike a
|
||
hardcoded ``~/.omnigent/...`` literal, this reflects the *actual*
|
||
effective path — so a state dir outside ``$HOME`` (an
|
||
``OMNIGENT_CONFIG_HOME`` / ``OMNIGENT_DATA_DIR`` override) renders as
|
||
its real location rather than a misleading ``~``.
|
||
|
||
:param path: The path to display, e.g.
|
||
``Path("/Users/alice/.omnigent/logs/server/local-server-ab12.log")``.
|
||
:returns: ``"~/.omnigent/..."`` when *path* is under ``$HOME``,
|
||
otherwise ``str(path)``.
|
||
"""
|
||
try:
|
||
return f"~/{path.relative_to(Path.home())}"
|
||
except ValueError:
|
||
# Not under $HOME (e.g. an OMNIGENT_DATA_DIR outside home).
|
||
return str(path)
|
||
|
||
|
||
def _display_config_path(path: Path) -> str:
|
||
"""
|
||
Format a config path for display, collapsing the home prefix to ``~``.
|
||
|
||
Thin wrapper over :func:`_display_path` kept for call-site readability
|
||
where the path is specifically the effective config file.
|
||
|
||
:param path: The config path to display, e.g.
|
||
``Path("/Users/alice/.omnigent/config.yaml")``.
|
||
:returns: ``"~/.omnigent/config.yaml"`` when *path* is under
|
||
``$HOME``, otherwise ``str(path)``.
|
||
"""
|
||
return _display_path(path)
|
||
|
||
|
||
def _load_global_config() -> dict[str, Any]: # type: ignore[explicit-any]
|
||
"""
|
||
Load the global omnigent config from ``~/.omnigent/config.yaml``.
|
||
|
||
Returns an empty dict when the file does not exist or is empty.
|
||
Top-level default keys (``default_agent``, ``server``,
|
||
``model``, ``harness``) hold plain string values. The optional
|
||
``auto_open_conversation`` key is a boolean. The optional
|
||
``auth:`` key holds a nested mapping —
|
||
``{"type": "databricks", "profile": "oss"}`` or
|
||
``{"type": "api_key", "api_key": "…"}`` — written by
|
||
``omnigent setup`` and used by the runtime to supply executor
|
||
credentials when an agent spec does not declare ``executor.auth``.
|
||
|
||
:returns: Parsed YAML as a dict, e.g.
|
||
``{"default_agent": "examples/hello_world.yaml",
|
||
"auth": {"type": "databricks", "profile": "oss"}}``.
|
||
"""
|
||
return load_global_config(_effective_global_config_path())
|
||
|
||
|
||
def _load_local_config() -> dict[str, Any]: # type: ignore[explicit-any]
|
||
"""
|
||
Load the project-level config from ``.omnigent/config.yaml`` in cwd.
|
||
|
||
Returns an empty dict when the file does not exist or is empty.
|
||
|
||
:returns: Parsed YAML as a dict.
|
||
"""
|
||
return load_local_config(Path.cwd() / _LOCAL_CONFIG_RELPATH)
|
||
|
||
|
||
def _load_effective_config() -> dict[str, Any]: # type: ignore[explicit-any]
|
||
"""
|
||
Merge global and project-level config.
|
||
|
||
Precedence (highest last): global (``~/.omnigent/config.yaml``)
|
||
→ local (``.omnigent/config.yaml`` in cwd). Project config
|
||
always wins so per-repo settings override user defaults.
|
||
|
||
:returns: Merged config dict.
|
||
"""
|
||
return {**_load_global_config(), **_load_local_config()}
|
||
|
||
|
||
def _peek_default_agent_harness(target: str) -> str | None:
|
||
"""
|
||
Return the canonical harness declared by a default-agent YAML, or ``None``.
|
||
|
||
Reads ``executor.harness`` / ``executor.type`` from a local YAML path so
|
||
:func:`_resolve_default_agent_target` can compare it to an explicit
|
||
``--harness``. Returns ``None`` for URLs, missing/unreadable files, or
|
||
specs that declare no harness — the caller treats ``None`` as "cannot
|
||
confirm a match".
|
||
|
||
:param target: The configured ``default_agent`` value, e.g.
|
||
``"/Users/me/.omnigent/agents/databricks_coding_agent.yaml"``.
|
||
:returns: The canonical harness, e.g. ``"openai-agents-sdk"``, or ``None``.
|
||
"""
|
||
if "://" in target:
|
||
return None
|
||
path = Path(target).expanduser()
|
||
if not path.is_file():
|
||
return None
|
||
try:
|
||
raw = yaml.safe_load(path.read_text()) or {}
|
||
except (OSError, yaml.YAMLError):
|
||
return None
|
||
if not isinstance(raw, dict):
|
||
return None
|
||
executor = raw.get("executor")
|
||
if not isinstance(executor, dict):
|
||
return None
|
||
declared = executor.get("harness") or executor.get("type")
|
||
if not isinstance(declared, str) or not declared:
|
||
return None
|
||
return canonicalize_harness(declared) or declared
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _FirstRunPlan:
|
||
"""The harness + optional default agent a bare ``run`` should launch.
|
||
|
||
Derived fresh from the configured credentials on each bare ``run`` and
|
||
never persisted (see :func:`_resolve_first_run_plan`).
|
||
|
||
:param harness: The canonical harness id to launch, e.g. ``"claude-sdk"``.
|
||
:param agent: The default agent target to launch (the bundled polly path
|
||
for Claude), or ``None`` for a bare harness REPL (codex / pi).
|
||
"""
|
||
|
||
harness: str
|
||
agent: str | None
|
||
|
||
|
||
def _bundled_example_path(name: str) -> str:
|
||
"""Return the filesystem path to a bundled example agent directory.
|
||
|
||
Located via the packaged ``omnigent.resources.examples`` (symlinks to
|
||
``examples/<name>`` in a dev checkout, real directories in an installed
|
||
wheel), mirroring how the model catalog is located.
|
||
|
||
:param name: Bundled example directory name, e.g. ``"polly"``.
|
||
:returns: Absolute path string to the agent directory.
|
||
"""
|
||
import importlib.resources
|
||
|
||
resource = importlib.resources.files("omnigent.resources.examples").joinpath(name)
|
||
# On a no-symlink Windows checkout the packaged symlink is a stub text file;
|
||
# dereference it to the real examples/<name> directory.
|
||
return str(resolve_repo_symlink(Path(str(resource))))
|
||
|
||
|
||
def _pick_first_run_harness() -> _FirstRunPlan | None:
|
||
"""Pick the harness a bare first ``run`` should launch, by configured creds.
|
||
|
||
Priority Claude → Codex → Pi over the ambient-merged config (a detected env
|
||
key / CLI login counts as configured). Claude gets the bundled polly
|
||
orchestrator as its default agent; Codex / Pi launch a bare harness REPL.
|
||
Shared with ``configure harnesses`` via
|
||
:func:`~omnigent.onboarding.provider_config.default_provider_for_harness`,
|
||
so the two surfaces agree on "what's configured".
|
||
|
||
:returns: A :class:`_FirstRunPlan`, or ``None`` when no harness has a usable
|
||
credential.
|
||
"""
|
||
from omnigent.onboarding.detected import effective_config_with_detected
|
||
from omnigent.onboarding.provider_config import (
|
||
default_provider_for_harness,
|
||
load_config,
|
||
)
|
||
|
||
config = effective_config_with_detected(load_config())
|
||
if default_provider_for_harness(config, "claude-sdk") is not None:
|
||
return _FirstRunPlan(harness="claude-sdk", agent=_bundled_example_path("polly"))
|
||
if default_provider_for_harness(config, "codex") is not None:
|
||
return _FirstRunPlan(harness="codex", agent=None)
|
||
if default_provider_for_harness(config, "pi") is not None:
|
||
return _FirstRunPlan(harness="pi", agent=None)
|
||
# Kimi authenticates against its own backend (``kimi login`` OAuth or a
|
||
# Moonshot API key) rather than the ambient-detected provider config, so
|
||
# ``default_provider_for_harness`` can't gate it. Fall back to "binary
|
||
# installed" as the readiness proxy: the executor will fail loud at the
|
||
# first turn if no provider is actually configured.
|
||
from omnigent.onboarding.harness_install import KIMI_KEY, harness_cli_installed
|
||
|
||
if harness_cli_installed(KIMI_KEY):
|
||
return _FirstRunPlan(harness="kimi", agent=None)
|
||
return None
|
||
|
||
|
||
def _resolve_first_run_plan() -> _FirstRunPlan | None:
|
||
"""Resolve the harness + default agent for a bare ``omnigent run``.
|
||
|
||
Adopts ambient-detected credentials, then picks a harness from what's
|
||
configured (Claude→polly / Codex / Pi). When nothing is configured,
|
||
prints a notice, drops the user into ``configure harnesses``, then
|
||
re-checks once.
|
||
|
||
The pick is **deliberately not persisted** as a global default: it is
|
||
derived state, recomputed on every bare ``run`` from the *current*
|
||
credentials. So a user who starts with only Codex (→ a codex REPL) and
|
||
later adds Claude is promoted to polly on their next bare ``run`` —
|
||
keeping polly as the primary experience — rather than being pinned to
|
||
the earlier fallback. An *explicit* default (a user-set global
|
||
``harness`` / ``default_agent``, or ``run <agent>`` / ``--harness``)
|
||
still short-circuits this path upstream and is always honored.
|
||
|
||
:returns: The chosen :class:`_FirstRunPlan`, or ``None`` when the user still
|
||
has no configured harness after the configure step — the caller exits
|
||
cleanly rather than erroring.
|
||
"""
|
||
# Adopt any ambient creds so a detected key/login becomes a real provider
|
||
# default, exactly as opening `configure harnesses` does (and announce what
|
||
# was auto-configured, so a never-set-up user sees which credentials we
|
||
# picked up). This persists *credentials* (the provider layer), NOT the
|
||
# agent/harness pick — the pick stays ephemeral so it tracks whatever creds
|
||
# are currently available.
|
||
_adopt_ambient_credentials()
|
||
|
||
plan = _pick_first_run_harness()
|
||
if plan is None:
|
||
ui.warn("Found no harnesses configured.")
|
||
_run_configure_harnesses_interactive()
|
||
plan = _pick_first_run_harness()
|
||
return plan
|
||
|
||
|
||
def _resolve_default_agent_target(
|
||
default_agent: str | None,
|
||
requested_harness: str | None,
|
||
) -> str | None:
|
||
"""
|
||
Decide the ``run`` target when no AGENT was passed on the command line.
|
||
|
||
- No ``default_agent`` → ``None`` (the no-AGENT ``--harness`` launcher
|
||
builds an ad-hoc spec, or ``run`` errors when no harness either).
|
||
- No ``--harness`` → the ``default_agent`` (the configured default
|
||
experience, unchanged).
|
||
- ``--harness X`` given with a ``default_agent`` whose harness is ``Y``:
|
||
use the ``default_agent`` when ``Y == X`` (harness matches, so the user
|
||
gets their richer configured agent); otherwise **warn** and return
|
||
``None`` so a minimal built-in ``X`` agent launches instead of forcing
|
||
``X`` onto a ``Y``-shaped spec (which would, e.g., point claude-sdk at a
|
||
gpt model and 400 with an API-type mismatch). When ``Y`` can't be
|
||
determined, fall back to the minimal launcher silently (can't assert a
|
||
mismatch, but also can't confirm a match).
|
||
|
||
:param default_agent: The configured ``default_agent`` value, or ``None``.
|
||
:param requested_harness: The explicit ``--harness`` value, or ``None``.
|
||
:returns: The target to run (``default_agent`` path) or ``None`` to use
|
||
the no-AGENT launcher.
|
||
"""
|
||
if not default_agent:
|
||
return None
|
||
if requested_harness is None:
|
||
return default_agent
|
||
requested = canonicalize_harness(requested_harness) or requested_harness
|
||
default_harness = _peek_default_agent_harness(str(default_agent))
|
||
if default_harness == requested:
|
||
return default_agent
|
||
if default_harness is not None:
|
||
click.echo(
|
||
f"omnigent: default agent '{default_agent}' uses harness "
|
||
f"{default_harness!r}, but you specified --harness {requested!r}; "
|
||
f"launching a minimal built-in {requested!r} agent instead.",
|
||
err=True,
|
||
)
|
||
return None
|
||
|
||
|
||
def _parse_config_bool(key: str, value: _ConfigValue) -> bool:
|
||
"""
|
||
Parse a boolean value from YAML or ``omnigent config KEY=VALUE``.
|
||
|
||
:param key: Config key being parsed, e.g.
|
||
``"auto_open_conversation"``.
|
||
:param value: Raw value from YAML or CLI parsing, e.g. ``"true"``.
|
||
:returns: Parsed boolean value.
|
||
:raises click.ClickException: If *value* is not a supported boolean.
|
||
"""
|
||
if isinstance(value, bool):
|
||
return value
|
||
if isinstance(value, str):
|
||
normalized = value.strip().lower()
|
||
if normalized in _CONFIG_TRUE_VALUES:
|
||
return True
|
||
if normalized in _CONFIG_FALSE_VALUES:
|
||
return False
|
||
raise click.ClickException(
|
||
f"Config key {key!r} must be a boolean (true/false, yes/no, on/off, or 1/0)."
|
||
)
|
||
|
||
|
||
def _resolve_auto_open_conversation_setting(cfg: dict[str, Any]) -> bool | None: # type: ignore[explicit-any]
|
||
"""
|
||
Resolve the explicit ``auto_open_conversation`` config value, if set.
|
||
|
||
Tri-state on purpose so callers can distinguish "the user has not
|
||
expressed a preference" (``None``) from an explicit opt-in/opt-out.
|
||
``omnigent run`` uses this to default the browser-open ON for
|
||
interactive launches while still honoring an explicit
|
||
``auto_open_conversation: false``; see :func:`run`.
|
||
|
||
:param cfg: Effective config dict from :func:`_load_effective_config`,
|
||
e.g. ``{"auto_open_conversation": True}``.
|
||
:returns: ``True`` / ``False`` when the key is present, or ``None``
|
||
when the user has not configured it.
|
||
:raises click.ClickException: If the configured value is not a
|
||
supported boolean.
|
||
"""
|
||
raw = cfg.get(_AUTO_OPEN_CONVERSATION_CONFIG_KEY)
|
||
if raw is None:
|
||
return None
|
||
return _parse_config_bool(_AUTO_OPEN_CONVERSATION_CONFIG_KEY, raw)
|
||
|
||
|
||
def _resolve_auto_open_conversation_from_config(cfg: dict[str, Any]) -> bool: # type: ignore[explicit-any]
|
||
"""
|
||
Resolve whether CLI launches should open conversation URLs.
|
||
|
||
Defaults to ``False`` when the user has not configured the key.
|
||
``omnigent run`` does not use this resolver — it defaults the
|
||
browser-open ON for interactive launches via
|
||
:func:`_resolve_auto_open_conversation_setting`.
|
||
|
||
:param cfg: Effective config dict from :func:`_load_effective_config`,
|
||
e.g. ``{"auto_open_conversation": True}``.
|
||
:returns: ``True`` when conversation links should be opened
|
||
automatically.
|
||
:raises click.ClickException: If the configured value is not a
|
||
supported boolean.
|
||
"""
|
||
setting = _resolve_auto_open_conversation_setting(cfg)
|
||
return setting if setting is not None else False
|
||
|
||
|
||
def _save_global_config( # type: ignore[explicit-any]
|
||
# Any (matching the yaml-boundary helpers above): config values are
|
||
# heterogeneous YAML scalars and nested mappings — e.g. the providers:
|
||
# block, whose entries come back as dict[str, object] from
|
||
# provider_entry_settings / set_default_provider. _ConfigValue can't
|
||
# express that interop without invariance errors against those object
|
||
# returns, so this stays the same Any boundary _load_*_config uses.
|
||
settings: Mapping[str, Any],
|
||
unset_keys: tuple[str, ...] = (),
|
||
deep_merge_keys: tuple[str, ...] = (),
|
||
) -> None:
|
||
"""
|
||
Merge *settings* into ``~/.omnigent/config.yaml`` and remove any
|
||
keys listed in *unset_keys*.
|
||
|
||
Creates the ``~/.omnigent/`` directory if it does not exist.
|
||
Values may be plain strings, booleans, or nested mappings (the
|
||
``auth:`` block written by ``omnigent setup``, or a ``providers:``
|
||
block written by ``omnigent setup --no-internal-beta``).
|
||
|
||
By default every key in *settings* **replaces** the existing value
|
||
wholesale (a shallow ``dict.update``). For keys listed in
|
||
*deep_merge_keys*, the incoming mapping is instead merged one level
|
||
deep into the existing mapping for that key — so passing a single
|
||
provider under ``providers:`` adds/updates that one entry without
|
||
dropping the others. Use the default (shallow replace) when the new
|
||
mapping must become the *entire* block (e.g. after
|
||
:func:`~omnigent.onboarding.provider_config.set_default_provider`,
|
||
which clears sibling ``default`` flags a deep-merge could not reach).
|
||
|
||
:param settings: Key/value pairs to set, e.g.
|
||
``{"default_agent": "/abs/path/agent.yaml",
|
||
"auto_open_conversation": True,
|
||
"auth": {"type": "databricks", "profile": "oss"}}``.
|
||
:param unset_keys: Keys to remove from the config, e.g.
|
||
``("server",)``.
|
||
:param deep_merge_keys: Keys whose mapping value should be merged
|
||
one level deep into the existing mapping rather than replacing
|
||
it, e.g. ``("providers",)`` to add one provider entry without
|
||
dropping the rest.
|
||
"""
|
||
cfg = _load_global_config()
|
||
for key, value in settings.items():
|
||
if key in deep_merge_keys and isinstance(value, Mapping):
|
||
existing = cfg.get(key)
|
||
merged = dict(existing) if isinstance(existing, Mapping) else {}
|
||
merged.update(value)
|
||
cfg[key] = merged
|
||
else:
|
||
cfg[key] = value
|
||
for key in unset_keys:
|
||
cfg.pop(key, None)
|
||
path = _effective_global_config_path()
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
with open(path, "w") as f:
|
||
yaml.safe_dump(cfg, f, default_flow_style=False, sort_keys=True)
|
||
|
||
|
||
def _materialize_bundled_example(name: str) -> Path:
|
||
"""
|
||
Copy a single bundled example YAML into the user config dir.
|
||
|
||
``uv tool install`` installs package files, not the repository checkout, so the
|
||
top-level ``examples/<name>`` paths are not available to users. Materialize a
|
||
user-editable copy under ``~/.omnigent/agents`` and never overwrite an
|
||
existing file so local edits survive reinstalls and reruns.
|
||
|
||
:param name: Filename of the bundled example (e.g.
|
||
``"databricks_coding_agent.yaml"``).
|
||
:returns: Absolute path to the materialized agent YAML.
|
||
"""
|
||
agent_path = _GLOBAL_AGENTS_DIR / name
|
||
if agent_path.exists():
|
||
return agent_path
|
||
|
||
agent_path.parent.mkdir(parents=True, exist_ok=True)
|
||
resource = resources.files("omnigent.resources.examples").joinpath(name)
|
||
text = resource.read_text(encoding="utf-8")
|
||
executable_placeholder = "__OMNIGENT_PYTHON_EXECUTABLE__"
|
||
text = text.replace('"${OMNIGENT_HOME:-$PWD}/.venv/bin/python"', executable_placeholder)
|
||
text = text.replace("${OMNIGENT_HOME:-$PWD}/.venv/bin/python", executable_placeholder)
|
||
text = text.replace(".venv/bin/python", sys.executable)
|
||
text = text.replace(executable_placeholder, sys.executable)
|
||
agent_path.write_text(text, encoding="utf-8")
|
||
return agent_path
|
||
|
||
|
||
def _materialize_internal_beta_agents() -> Path:
|
||
"""
|
||
Materialize every bundled internal-beta example and return the default's path.
|
||
|
||
:returns: Absolute path to the default agent YAML
|
||
(:data:`_INTERNAL_BETA_DEFAULT_AGENT_NAME`).
|
||
"""
|
||
default_path: Path | None = None
|
||
for name in _INTERNAL_BETA_BUNDLED_AGENTS:
|
||
path = _materialize_bundled_example(name)
|
||
if name == _INTERNAL_BETA_DEFAULT_AGENT_NAME:
|
||
default_path = path
|
||
assert default_path is not None, (
|
||
f"_INTERNAL_BETA_BUNDLED_AGENTS must include {_INTERNAL_BETA_DEFAULT_AGENT_NAME}"
|
||
)
|
||
return default_path
|
||
|
||
|
||
def _save_local_config(
|
||
settings: dict[str, str | bool],
|
||
unset_keys: tuple[str, ...] = (),
|
||
) -> None:
|
||
"""
|
||
Merge *settings* into ``.omnigent/config.yaml`` in cwd and remove
|
||
any keys listed in *unset_keys*.
|
||
|
||
Creates the ``.omnigent/`` directory if it does not exist.
|
||
|
||
:param settings: Key/value pairs to set, e.g.
|
||
``{"default_agent": "examples/agent.yaml",
|
||
"auto_open_conversation": True}``.
|
||
:param unset_keys: Keys to remove from the config, e.g.
|
||
``("server",)``.
|
||
"""
|
||
path = Path.cwd() / _LOCAL_CONFIG_RELPATH
|
||
cfg = _load_local_config()
|
||
cfg.update(settings)
|
||
for key in unset_keys:
|
||
cfg.pop(key, None)
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
with open(path, "w") as f:
|
||
yaml.safe_dump(cfg, f, default_flow_style=False, sort_keys=True)
|
||
|
||
|
||
def _default_db_uri() -> str:
|
||
"""Default DB URI for ``omnigent server`` — the machine-global
|
||
``<data_dir>/chat.db``.
|
||
|
||
Resolves to the same path the ``omnigent run`` daemon spawns its
|
||
local server against (``_local_data_dir()``, honoring
|
||
``OMNIGENT_DATA_DIR`` → else ``~/.omnigent``). Pinning ``server``
|
||
to the same DB as ``run`` means there is **one local DB — and so one
|
||
accounts admin — per machine**, instead of a fresh CWD-relative
|
||
``omnigent.db`` (and a fresh admin) for every directory you launch
|
||
from. ``--database-uri`` / the config file still override.
|
||
|
||
:returns: e.g. ``"sqlite:////home/alice/.omnigent/chat.db"``.
|
||
"""
|
||
from omnigent.host.local_server import _local_data_dir
|
||
|
||
return f"sqlite:///{_local_data_dir() / 'chat.db'}"
|
||
|
||
|
||
def _default_artifact_location() -> str:
|
||
"""Default artifact dir for ``omnigent server`` — ``<data_dir>/artifacts``.
|
||
|
||
Kept in lock-step with :func:`_default_db_uri` so a default-config
|
||
``omnigent server`` and ``omnigent run`` share one coherent
|
||
machine-global instance (same DB *and* same artifacts) — otherwise a
|
||
conversation created by one would reference files the other can't
|
||
resolve. ``--artifact-location`` / the config file still override.
|
||
|
||
:returns: e.g. ``"/home/alice/.omnigent/artifacts"``.
|
||
"""
|
||
from omnigent.host.local_server import _local_data_dir
|
||
|
||
return str(_local_data_dir() / "artifacts")
|
||
|
||
|
||
def _ensure_sqlite_parent_dir(db_uri: str) -> None:
|
||
"""Create the parent directory of a SQLite DB file if it's missing.
|
||
|
||
SQLite creates the ``.db`` file on first connect but **not** its
|
||
parent directory — an absent parent raises ``sqlite3.OperationalError:
|
||
unable to open database file``. The default ``server`` DB now lives at
|
||
``<data_dir>/chat.db`` (machine-global, honoring ``OMNIGENT_DATA_DIR``),
|
||
so a first-ever run — or any run after the data dir was cleared — must
|
||
create that dir before the stores connect. The daemon-spawned server
|
||
handles this in ``ensure_local_omnigent_server``; this is the equivalent for
|
||
the foreground ``omnigent server`` command.
|
||
|
||
No-op for non-SQLite URIs (Postgres etc.) and for in-memory SQLite.
|
||
|
||
:param db_uri: The resolved store DB URI, e.g.
|
||
``"sqlite:////home/alice/.omnigent/chat.db"`` or
|
||
``"postgresql://host/db"``.
|
||
:returns: None.
|
||
"""
|
||
from sqlalchemy.engine import make_url
|
||
|
||
url = make_url(db_uri)
|
||
if url.get_backend_name() != "sqlite":
|
||
return
|
||
# url.database is the filesystem path for file-backed SQLite, None or
|
||
# ":memory:" for in-memory — neither needs a parent dir.
|
||
if not url.database or url.database == ":memory:":
|
||
return
|
||
Path(url.database).parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
|
||
def _maybe_prompt_first_admin(account_store: Any, auth_provider: Any, *, auto_open: bool) -> None: # type: ignore[explicit-any] # SqlAlchemyAccountStore | None, AuthProvider
|
||
"""Interactively claim the first admin on a TTY when setup is pending.
|
||
|
||
The "terminal" entry point of first-run setup. It's the FALLBACK,
|
||
not the default: when the browser is about to auto-open the web
|
||
Create-admin form (the default ``--open`` on a loopback server), we
|
||
skip the prompt and let the browser own setup — otherwise the
|
||
terminal prompt would block before the lifespan ever opens the
|
||
browser, so the form would never appear.
|
||
|
||
No-ops unless ALL of:
|
||
|
||
- accounts mode is active (``account_store`` is not ``None``);
|
||
- no password-having account exists yet (a ``--admin-password`` /
|
||
``INIT_ADMIN_PASSWORD`` would already have created one, and a
|
||
re-boot already has an admin);
|
||
- stdin AND stdout are a TTY — a headless / piped / agent run must
|
||
NOT block on a prompt (it falls through to the web form);
|
||
- the browser is NOT auto-opening a usable form, i.e. ``--no-open``
|
||
was passed OR the base URL isn't loopback (remote-over-SSH, where
|
||
opening a browser on the server box is useless but a terminal IS
|
||
available).
|
||
|
||
On success, creates the admin and mints the loopback CLI token so a
|
||
subsequent ``omnigent run`` against this server is signed in.
|
||
|
||
:param account_store: The accounts store, or ``None`` in
|
||
header/OIDC mode (then this is a no-op).
|
||
:param auth_provider: The active auth provider; its accounts config
|
||
supplies the cookie secret / base URL / session TTL.
|
||
:param auto_open: The resolved ``--open/--no-open`` flag. When True
|
||
and the base URL is loopback, the lifespan opens the browser to
|
||
the form, so we defer to it and skip the prompt.
|
||
:returns: None.
|
||
"""
|
||
if account_store is None:
|
||
return
|
||
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
||
return
|
||
if any(u.has_password for u in account_store.list_users()):
|
||
return
|
||
|
||
from omnigent.server.accounts_bootstrap import (
|
||
_is_loopback_base_url,
|
||
_mint_loopback_cli_token,
|
||
resolve_admin_username,
|
||
)
|
||
from omnigent.server.auth import UnifiedAuthProvider
|
||
from omnigent.server.passwords import hash_password
|
||
from omnigent.server.routes.accounts_auth import _MIN_PASSWORD_LENGTH
|
||
|
||
# Read the accounts config off the concrete provider (same direct
|
||
# access app.py uses). isinstance-narrowed so mypy sees the attribute
|
||
# rather than reaching through getattr(..., "<literal>").
|
||
base_url: str | None = None
|
||
if isinstance(auth_provider, UnifiedAuthProvider):
|
||
cfg = auth_provider._accounts_config
|
||
base_url = cfg.base_url if cfg is not None else None
|
||
# Defer to the browser form when it's going to open (default --open
|
||
# on a loopback server). Only prompt when no browser form will appear.
|
||
if auto_open and base_url is not None and _is_loopback_base_url(base_url):
|
||
return
|
||
|
||
click.echo("\n First-run setup — create the admin account for this server.")
|
||
username = click.prompt(" Username", default=resolve_admin_username()).strip().lower()
|
||
while True:
|
||
password = click.prompt(" Password", hide_input=True, confirmation_prompt=True)
|
||
if len(password) >= _MIN_PASSWORD_LENGTH:
|
||
break
|
||
click.echo(f" Password must be at least {_MIN_PASSWORD_LENGTH} characters.", err=True)
|
||
|
||
try:
|
||
account_store.create_user_with_password(username, hash_password(password), is_admin=True)
|
||
except ValueError:
|
||
# Raced another claimer (e.g. someone hit the web form first).
|
||
click.echo(" An admin was just created elsewhere — skipping.", err=True)
|
||
return
|
||
|
||
# Mint the loopback CLI token so `omnigent run` is signed in.
|
||
# (Reuses cfg/base_url resolved above.)
|
||
if (
|
||
cfg is not None
|
||
and base_url is not None
|
||
and cfg.cookie_secret is not None
|
||
and _is_loopback_base_url(base_url)
|
||
):
|
||
_mint_loopback_cli_token(
|
||
username,
|
||
base_url=base_url,
|
||
cookie_secret=cfg.cookie_secret,
|
||
session_ttl_hours=cfg.session_ttl_hours,
|
||
)
|
||
click.echo(f" ✓ Admin '{username}' created. Sign in at the server URL.\n")
|
||
|
||
|
||
def _create_artifact_store(location: str) -> Any: # type: ignore[explicit-any] # returns ArtifactStore protocol (optional deps)
|
||
"""
|
||
Create an artifact store based on the location URI scheme.
|
||
|
||
``dbfs:/Volumes/...`` URIs use
|
||
:class:`DatabricksVolumesArtifactStore` (requires
|
||
``databricks-sdk``). All other locations use
|
||
:class:`LocalArtifactStore`.
|
||
|
||
:param location: Artifact storage location, e.g.
|
||
``"./artifacts"`` for local or
|
||
``"dbfs:/Volumes/cat/schema/vol"`` for UC Volumes.
|
||
:returns: An :class:`ArtifactStore` instance.
|
||
"""
|
||
if location.startswith("dbfs:/Volumes/"):
|
||
from omnigent.stores.artifact_store.databricks_volumes import (
|
||
DatabricksVolumesArtifactStore,
|
||
)
|
||
|
||
return DatabricksVolumesArtifactStore(location)
|
||
|
||
from omnigent.stores.artifact_store.local import LocalArtifactStore
|
||
|
||
return LocalArtifactStore(location)
|
||
|
||
|
||
def _preregister_agent( # type: ignore[explicit-any] # agent_store / artifact_store / agent_cache typed Any to avoid import cycle
|
||
agent_source: Path,
|
||
agent_store: Any,
|
||
artifact_store: Any,
|
||
agent_cache: Any,
|
||
) -> str | None:
|
||
"""
|
||
Register an agent from a directory or standalone YAML file.
|
||
|
||
Materializes *agent_source* into a uniform bundle directory via
|
||
:func:`omnigent.spec.materialize_bundle`, tars it, validates
|
||
the spec, and creates (or replaces) the agent in the store. This
|
||
runs at server startup for each ``--agent`` flag.
|
||
|
||
:param agent_source: Either an agent-image directory containing
|
||
``config.yaml`` (standard omnigent shape) or a standalone
|
||
omnigent YAML file (e.g.
|
||
``examples/coding_supervisor.yaml``). The file-vs-directory
|
||
branch lives inside ``materialize_bundle``; this function
|
||
operates uniformly on a directory downstream of it.
|
||
:param agent_store: The AgentStore for agent metadata.
|
||
:param artifact_store: The ArtifactStore for bundle storage.
|
||
:param agent_cache: The AgentCache. Required so the on-disk
|
||
extracted-bundle tier (cache_dir/<agent_id>/) is swapped
|
||
in lockstep with the artifact-store update — otherwise a
|
||
persistent session reuses the prior extraction and any
|
||
newly-added local-tool files (or other bundle edits) are
|
||
silently ignored on the next request.
|
||
:returns: The registered agent id, or ``None`` if the source
|
||
spec has no name and is skipped.
|
||
"""
|
||
import gzip
|
||
import hashlib
|
||
import io
|
||
import tarfile
|
||
|
||
from omnigent.db.utils import generate_agent_id
|
||
from omnigent.spec import load, materialize_bundle
|
||
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
bundle_dir = materialize_bundle(agent_source, Path(tmpdir) / "bundle")
|
||
|
||
# Build tarball in memory from the materialized bundle dir.
|
||
# ``arcname="."`` puts the contents at the tarball root so
|
||
# extraction produces the same shape ``spec.load`` expects.
|
||
# Pin gzip mtime so sha256(bundle_bytes) is deterministic across calls.
|
||
buf = io.BytesIO()
|
||
with (
|
||
gzip.GzipFile(fileobj=buf, mode="wb", mtime=0) as gz,
|
||
tarfile.open(fileobj=gz, mode="w") as tar,
|
||
):
|
||
tar.add(str(bundle_dir), arcname=".")
|
||
bundle_bytes = buf.getvalue()
|
||
|
||
# Validate via the materialized directory directly — cheaper
|
||
# than round-tripping through extract.
|
||
spec = load(bundle_dir)
|
||
|
||
if spec.name is None:
|
||
click.echo(f" warning: {agent_source} has no name, skipping")
|
||
return None
|
||
|
||
# Idempotent registration. Mirrors
|
||
# :func:`omnigent.inner.cli._omnigent_register_yaml_bundle` —
|
||
# see designs/RUN_OMNIGENT_SESSION_RESUMPTION.md. Reusing the
|
||
# existing ``agent_id`` (rather than delete + recreate)
|
||
# is load-bearing for ``--continue``: deleting the old
|
||
# row cascades through the ``tasks`` FK
|
||
# (``ondelete=CASCADE`` in
|
||
# :class:`omnigent.db.db_models.SqlTask`), wiping every
|
||
# prior task — which makes the next ``--continue``
|
||
# filter by ``agent_id`` return zero conversations and
|
||
# exit ``"No prior conversation for agent ..."``. Update
|
||
# the bundle in place and only refresh
|
||
# ``bundle_location`` when the content hash actually
|
||
# changed so the row stays stable across no-op restarts.
|
||
bundle_hash = hashlib.sha256(bundle_bytes).hexdigest()
|
||
existing = agent_store.get_by_name(spec.name)
|
||
if existing is not None:
|
||
new_loc = f"{existing.id}/{bundle_hash}"
|
||
if existing.bundle_location != new_loc:
|
||
artifact_store.put(new_loc, bundle_bytes)
|
||
agent_store.update(existing.id, bundle_location=new_loc)
|
||
# Swap the cache's extracted bundle in lockstep. Without
|
||
# this, ``AgentCache.load`` will hit Tier 2 (disk —
|
||
# ``cache_dir/<agent_id>/``) on the next request and
|
||
# return the OLD spec, even though the artifact store
|
||
# and the DB row both point at the new bundle.
|
||
# Mirrors what the HTTP PUT /agents/{id} route does at
|
||
# ``omnigent/server/routes/agents.py:248``.
|
||
# ``--agent`` registers operator-authored template agents,
|
||
# so ${VAR} may expand against the server env here.
|
||
agent_cache.replace(existing.id, new_loc, bundle_bytes, expand_env=True)
|
||
click.echo(f" agent: {spec.name} (from {agent_source})")
|
||
return cast(str, existing.id)
|
||
|
||
agent_id = generate_agent_id()
|
||
loc = f"{agent_id}/{bundle_hash}"
|
||
artifact_store.put(loc, bundle_bytes)
|
||
agent_store.create(
|
||
agent_id=agent_id,
|
||
name=spec.name,
|
||
bundle_location=loc,
|
||
description=spec.description,
|
||
)
|
||
click.echo(f" agent: {spec.name} (from {agent_source})")
|
||
return agent_id
|
||
|
||
|
||
def _format_version() -> str:
|
||
"""Render the version line shown by ``--version`` and ``version``.
|
||
|
||
Always includes the package version. When the build hook in
|
||
``setup.py`` wrote ``omnigent/_build_info.py``, the line is
|
||
additionally annotated with the short commit SHA and the build
|
||
time in ISO-8601 UTC. For source checkouts that have never
|
||
been built, only the bare version prints — matching the
|
||
behavior before this feature shipped.
|
||
|
||
:returns: Either ``"omnigent 0.1.0"`` (no build info), or
|
||
``"omnigent 0.1.0 (010cf77c, built 2026-05-21T14:34:45Z)"``.
|
||
"""
|
||
import datetime
|
||
|
||
from omnigent.update_check import _read_build_info
|
||
from omnigent.version import VERSION
|
||
|
||
version_str = VERSION
|
||
info = _read_build_info()
|
||
if info is None:
|
||
return f"omnigent {version_str}"
|
||
epoch, sha = info
|
||
when = datetime.datetime.fromtimestamp(epoch, tz=datetime.timezone.utc).strftime(
|
||
"%Y-%m-%dT%H:%M:%SZ"
|
||
)
|
||
if sha:
|
||
# Short SHA (first 8 chars) — enough to disambiguate in bug
|
||
# reports without making the line unwieldy.
|
||
return f"omnigent {version_str} ({sha[:8]}, built {when})"
|
||
# _build_info exists but has no SHA (built without git available).
|
||
return f"omnigent {version_str} (built {when})"
|
||
|
||
|
||
def _print_version_callback(ctx: click.Context, _param: click.Parameter, value: bool) -> None:
|
||
"""Click callback that lazily renders the version line and exits.
|
||
|
||
We deliberately do NOT use ``@click.version_option(version=...)``
|
||
here: that decorator evaluates its ``version`` argument at module
|
||
import time, which would call ``_format_version()`` — and through
|
||
it ``_read_build_info()`` — during ``omnigent.cli`` import. The
|
||
successful sub-import would then set ``omnigent._build_info`` as
|
||
an attribute on the ``omnigent`` package object. Once that
|
||
attribute exists, ``from omnigent import _build_info`` short-
|
||
circuits *before* consulting ``sys.modules``, defeating the
|
||
test-suite's ``sys.modules[...] = None`` blocker and making most
|
||
update_check tests pick up live values from disk.
|
||
|
||
Doing the work in a callback keeps the import side-effect-free:
|
||
``_format_version`` runs only when the user actually passes
|
||
``--version`` on the command line.
|
||
"""
|
||
if not value or ctx.resilient_parsing:
|
||
return
|
||
click.echo(_format_version())
|
||
ctx.exit()
|
||
|
||
|
||
class _OmnigentCLI(click.Group):
|
||
"""Top-level group that prints the brand lockup above its help.
|
||
|
||
The Otto + wordmark lockup is drawn on stderr (decoration) and is
|
||
TTY-gated by :func:`omnigent.inner.ui.show_banner`, so ``omnigent
|
||
--help`` shows the banner interactively while piped/CI help stays
|
||
clean. Only the top-level group overrides help; subcommand help
|
||
(``omnigent run --help``) is untouched.
|
||
"""
|
||
|
||
def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
|
||
from omnigent.inner import ui
|
||
|
||
if ui.show_banner():
|
||
from omnigent.version import VERSION
|
||
|
||
epilogue = [("Get started", "omnigent setup")]
|
||
if VERSION:
|
||
epilogue.insert(0, ("Version", VERSION))
|
||
ui.print_landing(tagline="all your agents, one cli", epilogue=epilogue)
|
||
super().format_help(ctx, formatter)
|
||
|
||
|
||
@click.group(cls=_OmnigentCLI)
|
||
@click.option(
|
||
"--version",
|
||
is_flag=True,
|
||
callback=_print_version_callback,
|
||
expose_value=False,
|
||
is_eager=True,
|
||
help="Show the version and exit.",
|
||
)
|
||
def cli() -> None:
|
||
"""Omnigent CLI."""
|
||
|
||
|
||
# Names of every subcommand the click group owns. Used by
|
||
# :func:`main` to reject the removed top-level ad-hoc chat path
|
||
# before click reports an opaque "no such command" error.
|
||
# Keep in sync with ``@cli.command()`` decorations below.
|
||
_CLICK_SUBCOMMANDS: frozenset[str] = frozenset(
|
||
{
|
||
"antigravity",
|
||
"attach",
|
||
"claude",
|
||
"codex",
|
||
"config",
|
||
"cursor",
|
||
"debby",
|
||
"debug",
|
||
"goose",
|
||
"hermes",
|
||
"host",
|
||
"kimi",
|
||
"kiro",
|
||
"lakebox",
|
||
"login",
|
||
"opencode",
|
||
"pane-picker",
|
||
"pane-split",
|
||
"pi",
|
||
"polly",
|
||
"qwen",
|
||
"resume",
|
||
"run",
|
||
"session",
|
||
"sandbox",
|
||
"server",
|
||
"setup",
|
||
"stop",
|
||
"update",
|
||
"upgrade",
|
||
"version",
|
||
}
|
||
)
|
||
|
||
|
||
def _should_skip_update_check(argv: list[str]) -> bool:
|
||
"""Decide whether the update notice should be suppressed for *argv*.
|
||
|
||
Skipped for help / version requests, internal TUI subcommands
|
||
(``pane-split`` / ``pane-picker``, invoked by the terminal UI rather
|
||
than the user), and ``upgrade`` (and its ``update`` alias) itself
|
||
(pointing the user at ``omni upgrade`` while they are running it is
|
||
noise).
|
||
|
||
:param argv: CLI arguments without the program name, e.g.
|
||
``["run", "agent.yaml"]``.
|
||
:returns: ``True`` when the update notice should not be shown.
|
||
"""
|
||
if not argv:
|
||
return True
|
||
return argv[0] in {
|
||
"--help",
|
||
"-h",
|
||
"--version",
|
||
"version",
|
||
"update",
|
||
"upgrade",
|
||
"pane-split",
|
||
"pane-picker",
|
||
}
|
||
|
||
|
||
def main() -> None:
|
||
"""
|
||
Console-script entry point for ``omnigent``.
|
||
|
||
Dispatches to the click CLI for subcommands like ``run``,
|
||
``attach``, and ``server``. The removed top-level ad-hoc chat
|
||
shape (``omnigent [--flags] [prompt]``) is rejected here so it
|
||
cannot fall back to the legacy in-process runner path.
|
||
|
||
Also inserts the current working directory at ``sys.path[0]``
|
||
so dotted callables declared in user YAMLs (``callable:
|
||
mypackage.mymodule.my_fn``) resolve against the user's project,
|
||
not the console-script's install directory. Console entry
|
||
points put the script's own directory at sys.path[0] by
|
||
default, which is almost never what a CLI that imports
|
||
user-authored modules wants.
|
||
|
||
Sets up the always-on CLI diagnostics log before Click dispatch
|
||
so unhandled exceptions are captured even when the user didn't
|
||
enable ``--log`` or ``--debug-events``.
|
||
"""
|
||
cwd = os.getcwd()
|
||
if cwd not in sys.path:
|
||
sys.path.insert(0, cwd)
|
||
|
||
# Relocate pre-rename ~/.omniagents state before anything reads ~/.omnigent
|
||
# (update-check cache, diagnostics logs, config). No-op once migrated.
|
||
_migrate_legacy_state_dir()
|
||
|
||
argv = sys.argv[1:]
|
||
|
||
# Bare ``omnigent`` with no args behaves like ``omnigent run`` on an
|
||
# interactive terminal: ``run`` resolves the configured default agent /
|
||
# first-run plan and drops into ``setup`` when nothing is configured. In
|
||
# a non-interactive context (pipe, CI, no TTY) fall back to ``--help`` so
|
||
# we never launch a REPL that would hang waiting on stdin.
|
||
if not argv:
|
||
argv = ["run"] if sys.stdin.isatty() else ["--help"]
|
||
|
||
# Shorthand: ``omnigent --harness claude [opts]`` →
|
||
# ``run --harness claude [opts]``. Click group-level options are
|
||
# intentionally tiny (currently only help/version); runner flags live on
|
||
# ``run``. Treat a leading non-top-level flag as bare-run shorthand so
|
||
# users can type the natural no-AGENT launcher form.
|
||
if argv and argv[0].startswith("-") and argv[0] not in {"--help", "-h", "--version"}:
|
||
argv = ["run", *argv]
|
||
|
||
# Shorthand: ``omnigent myagent.yaml [opts]`` → ``run myagent.yaml [opts]``.
|
||
# Allows ``omnigent`` to act as a transparent alias for ``omnigent run``
|
||
# when the first positional argument is an agent path.
|
||
if _is_run_shorthand(argv):
|
||
argv = ["run", *argv]
|
||
|
||
if argv and _is_server_url(argv[0]):
|
||
click.echo(
|
||
"Error: server URLs must be passed with --server. "
|
||
f"Use `omnigent run --server {argv[0]}`.",
|
||
err=True,
|
||
)
|
||
raise SystemExit(2)
|
||
|
||
if _is_removed_ad_hoc_invocation(argv):
|
||
click.echo(
|
||
"Error: top-level ad-hoc chat was removed. Use "
|
||
"`omnigent run <agent.yaml>` or "
|
||
"`omnigent run --harness <harness>`.",
|
||
err=True,
|
||
)
|
||
raise SystemExit(2)
|
||
|
||
# Always-on diagnostics — captures exceptions, lifecycle events,
|
||
# and warnings to ~/.omnigent/logs/cli-*.log even when --log
|
||
# (conversation JSON) and --debug-events (SSE tape) are off.
|
||
# Skip for pure help/version so quick invocations don't create
|
||
# log litter.
|
||
if argv[0] in {"--help", "-h", "--version"}:
|
||
cli(args=argv)
|
||
return
|
||
|
||
from omnigent.cli_diagnostics import (
|
||
log_cli_error_hint,
|
||
log_cli_exception,
|
||
print_setup_hint,
|
||
setup_cli_logging,
|
||
)
|
||
|
||
setup_cli_logging(argv)
|
||
|
||
# ``omnigent setup`` IS the setup wizard — if it fails, telling the
|
||
# user to "run omnigent setup" would be circular. ``upgrade`` (and its
|
||
# ``update`` alias) is excluded too: its failures (unreachable index,
|
||
# dev checkout, install error) are never about a missing model
|
||
# credential, so the setup hint would only mislead.
|
||
suggest_setup = argv[0] not in {"setup", "update", "upgrade"}
|
||
|
||
# Lightweight update notice: only on an interactive terminal and only
|
||
# for user-facing commands. Reads a cached "latest PyPI version" and
|
||
# prints at most once per release (the network refresh runs detached,
|
||
# off the hot path). Never blocks; any failure is swallowed inside.
|
||
if not _should_skip_update_check(argv) and sys.stderr.isatty():
|
||
from omnigent.update_check import maybe_show_update_notice
|
||
|
||
maybe_show_update_notice()
|
||
|
||
try:
|
||
cli(args=argv, standalone_mode=False)
|
||
except click.ClickException as exc:
|
||
log_cli_exception(exc, prefix="Click CLI error")
|
||
exc.show()
|
||
if suggest_setup:
|
||
print_setup_hint()
|
||
raise SystemExit(exc.exit_code) from exc
|
||
except click.Abort as exc:
|
||
# Ctrl+C / user cancel — no hint, the user knows what they did.
|
||
log_cli_exception(exc, prefix="Aborted CLI")
|
||
click.echo("Aborted!", err=True)
|
||
raise SystemExit(1) from exc
|
||
except Exception as exc:
|
||
log_cli_error_hint(exc)
|
||
if suggest_setup:
|
||
print_setup_hint()
|
||
raise
|
||
|
||
|
||
def _is_run_shorthand(argv: list[str]) -> bool:
|
||
"""Return True when *argv* looks like ``omnigent <target> [opts]``
|
||
where *target* is an agent YAML/directory rather than a subcommand.
|
||
|
||
Used by :func:`main` to transparently redirect
|
||
``omnigent myagent.yaml --model m`` to
|
||
``omnigent run myagent.yaml --model m``.
|
||
|
||
:param argv: CLI arguments without the program name, e.g.
|
||
``["myagent.yaml", "--model", "m"]``.
|
||
:returns: ``True`` when the first positional argument looks like a
|
||
run target (file path).
|
||
"""
|
||
if not argv:
|
||
return False
|
||
first = argv[0]
|
||
if first.startswith("-"):
|
||
return False # leading flag, not a positional target
|
||
if first in _CLICK_SUBCOMMANDS:
|
||
return False # already a known subcommand
|
||
if _is_server_url(first):
|
||
return False
|
||
# Accept paths ending with .yaml/.yml and explicit relative/absolute
|
||
# paths. Server addresses are only accepted through ``--server``.
|
||
return (
|
||
first.endswith((".yaml", ".yml")) or first.startswith(("./", "../")) or (os.sep in first)
|
||
)
|
||
|
||
|
||
def _is_server_url(value: str) -> bool:
|
||
"""Return whether *value* is a server URL.
|
||
|
||
:param value: CLI argument value, e.g. ``"http://localhost:6767"``.
|
||
:returns: ``True`` for ``http://`` or ``https://`` URLs.
|
||
"""
|
||
return value.startswith(("http://", "https://"))
|
||
|
||
|
||
def _is_removed_ad_hoc_invocation(argv: list[str]) -> bool:
|
||
"""
|
||
Decide whether *argv* targets the removed top-level ad-hoc chat.
|
||
|
||
True when:
|
||
- The first non-flag token isn't a known click subcommand and is
|
||
a quoted multi-word prompt (e.g.
|
||
``omnigent "what does this repo do?"``) — the free-text shape
|
||
the removed top-level ad-hoc chat accepted.
|
||
|
||
False when the first non-flag token matches a known
|
||
subcommand (``omnigent run ...``, ``omnigent attach ...``),
|
||
when the user asks for top-level help/version
|
||
(``omnigent --help``, ``omnigent --version``), or when the
|
||
token is a single command-shaped word (e.g. ``omnigent blah``)
|
||
— those stay on the click path so an unknown command produces
|
||
click's standard "No such command" error rather than the ad-hoc
|
||
removal notice.
|
||
|
||
:param argv: Argv without the program name, e.g.
|
||
``sys.argv[1:]``.
|
||
:returns: True for removed ad-hoc dispatch, False for click dispatch.
|
||
"""
|
||
if not argv:
|
||
return False
|
||
# Top-level click flags (``--help`` / ``-h`` / ``--version``)
|
||
# should go through click so the user sees the click group's
|
||
# help listing subcommands, not the legacy argparse help.
|
||
if argv[0] in {"--help", "-h", "--version"}:
|
||
return False
|
||
# Skip leading flags to find the first positional. If all
|
||
# tokens are flags (e.g. ``omnigent --system-prompt "..."``),
|
||
# treat it as removed ad-hoc chat rather than handing it to click
|
||
# as a top-level option.
|
||
for token in argv:
|
||
if token.startswith("-"):
|
||
continue
|
||
if token in _CLICK_SUBCOMMANDS:
|
||
return False
|
||
# A single command-shaped word (no whitespace) is an unknown
|
||
# subcommand: hand it to click for its standard "No such
|
||
# command" error. Only a quoted multi-word prompt matches the
|
||
# removed top-level ad-hoc chat shape.
|
||
return any(ch.isspace() for ch in token)
|
||
return True
|
||
|
||
|
||
def _runner_loopback_host(host: str) -> str:
|
||
"""Return a loopback-safe host for local runner callbacks.
|
||
|
||
:param host: Server bind host, e.g. ``"0.0.0.0"``.
|
||
:returns: Hostname the local runner can call back, e.g.
|
||
``"127.0.0.1"``.
|
||
"""
|
||
return "127.0.0.1" if host in {"0.0.0.0", "::", ""} else host
|
||
|
||
|
||
_HOST_PID_PATH = Path.home() / ".omnigent" / "host.pid"
|
||
|
||
|
||
# host.pid records the daemon PID + the "target" it serves: a normalized
|
||
# server URL for remote/explicit targets, or the literal marker ``"local"``
|
||
# for a daemon that owns a local Omnigent server. Daemon reuse is keyed on this
|
||
# target (real URLs never collide with the marker).
|
||
_LOCAL_DAEMON_MARKER = "local"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _HostDaemonRecord:
|
||
"""
|
||
Local registry record for one background host daemon.
|
||
|
||
:param pid: Process id of the background daemon, e.g. ``4242``.
|
||
:param target: Normalized daemon target, e.g.
|
||
``"https://example.databricksapps.com"`` or ``"local"``.
|
||
:param mode: Launch mode, either ``"server"`` or ``"local"``.
|
||
:param server_url: Normalized requested server URL for ``"server"``
|
||
mode, e.g. ``"https://example.databricksapps.com"``. ``None``
|
||
for local mode.
|
||
:param log_path: Daemon log file path, e.g.
|
||
``"/Users/me/.omnigent/logs/host-daemon/daemon-abc.log"``.
|
||
:param started_at: Unix epoch seconds when the daemon was spawned,
|
||
e.g. ``1710000000``.
|
||
:param host_id: Local host id advertised to Omnigent servers, e.g.
|
||
``"host_abc123"``. ``None`` for legacy records.
|
||
:param resolved_server_url: Concrete local server URL discovered for
|
||
local mode, e.g. ``"http://127.0.0.1:8123"``. ``None`` until
|
||
discovery succeeds or for remote mode.
|
||
:param config_sig: Signature of the server-affecting config (resolved
|
||
auth source) the daemon was spawned under, e.g.
|
||
``"3f9a1c2b4d5e6f70"`` (see :func:`_server_config_signature`).
|
||
``None`` for legacy records written before config-signature
|
||
tracking existed; a ``None`` signature is never treated as a
|
||
config mismatch (we can't know what it was started with).
|
||
"""
|
||
|
||
pid: int
|
||
target: str
|
||
mode: str
|
||
server_url: str | None
|
||
log_path: str | None
|
||
started_at: int
|
||
host_id: str | None = None
|
||
resolved_server_url: str | None = None
|
||
config_sig: str | None = None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _HostHttpResult:
|
||
"""
|
||
Decoded Omnigent management HTTP response.
|
||
|
||
:param status_code: HTTP status code, e.g. ``200``. ``0`` means no
|
||
HTTP response was received because the request failed locally.
|
||
:param body: Decoded JSON object or response text, e.g.
|
||
``{"data": []}`` or ``"not found"``.
|
||
"""
|
||
|
||
status_code: int
|
||
body: _HostJsonObject | str
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _HostSessionsTableWidths:
|
||
"""
|
||
Column widths for one host status sessions table.
|
||
|
||
:param session_id: Width for the ``Session ID`` column, e.g. ``41``.
|
||
:param runner_id: Width for the ``Runner ID`` column, e.g. ``44``.
|
||
:param title: Width for the ``Title`` column, e.g. ``28``.
|
||
:param workspace: Optional width for ``Workspace``, e.g. ``48``.
|
||
``None`` means the terminal is too narrow to show it.
|
||
"""
|
||
|
||
session_id: int
|
||
runner_id: int
|
||
title: int
|
||
workspace: int | None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _DaemonSessionsResult:
|
||
"""
|
||
Sessions fetched for one daemon target.
|
||
|
||
:param base_url: Omnigent server base URL, e.g.
|
||
``"https://example.databricksapps.com"``. ``None`` when a
|
||
local daemon's server cannot be discovered.
|
||
:param sessions: Session rows owned by the daemon host id.
|
||
:param error: Human-readable error text, or ``None`` on success.
|
||
"""
|
||
|
||
base_url: str | None
|
||
sessions: list[_HostSessionRow]
|
||
error: str | None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _SessionsPageResult:
|
||
"""
|
||
Decoded sessions page.
|
||
|
||
:param sessions: Session rows returned by the page.
|
||
:param last_id: Last session id in the page, e.g. ``"conv_abc123"``.
|
||
:param has_more: Whether another page should be fetched.
|
||
:param error: Human-readable error text, or ``None`` on success.
|
||
"""
|
||
|
||
sessions: list[_HostSessionRow]
|
||
last_id: str | None
|
||
has_more: bool
|
||
error: str | None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _SessionPagesResult:
|
||
"""
|
||
Accumulated sessions from a paginated query.
|
||
|
||
:param sessions: Session rows across all fetched pages.
|
||
:param error: Human-readable error text, or ``None`` on success.
|
||
"""
|
||
|
||
sessions: list[_HostSessionRow]
|
||
error: str | None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _SpawnedDaemonProcess:
|
||
"""
|
||
Background host daemon process metadata.
|
||
|
||
:param pid: Spawned process id, e.g. ``4242``.
|
||
:param log_path: Daemon log path, e.g.
|
||
``"/Users/me/.omnigent/logs/host-daemon/daemon-abc.log"``.
|
||
"""
|
||
|
||
pid: int
|
||
log_path: str
|
||
|
||
|
||
def _normalize_daemon_target(server_url: str | None) -> str:
|
||
"""
|
||
Normalize a daemon target key.
|
||
|
||
:param server_url: Requested Omnigent server URL, e.g.
|
||
``"https://example.databricksapps.com/"``. ``None`` or empty
|
||
string selects local mode.
|
||
:returns: ``"local"`` for local mode, otherwise the URL without a
|
||
trailing slash.
|
||
"""
|
||
return _LOCAL_DAEMON_MARKER if not server_url else server_url.rstrip("/")
|
||
|
||
|
||
def _daemon_host_online(record: _HostDaemonRecord, *, timeout_s: float = 2.0) -> bool:
|
||
"""
|
||
Probe whether a daemon's host is currently online on its server.
|
||
|
||
A daemon process being alive (PID check) does not mean its WebSocket
|
||
tunnel to the Omnigent server is up: the server only reports the host
|
||
``online`` while a daemon holds an authenticated tunnel and has
|
||
heartbeated within ``HOST_LIVENESS_TTL_S``. After a server restart,
|
||
an ungraceful daemon death, or a flapping tunnel, the daemon can be a
|
||
"zombie" — alive but not registered. This probe distinguishes the two
|
||
so reuse can heal instead of polling a zombie until timeout.
|
||
|
||
:param record: Daemon record to probe.
|
||
:param timeout_s: Per-request HTTP timeout in seconds, e.g. ``2.0``.
|
||
:returns: ``True`` only when the server reports the record's host id
|
||
as ``"online"``; ``False`` if the host id is unknown, the server
|
||
is unreachable, or the host reports offline.
|
||
"""
|
||
from omnigent.claude_native_bridge import url_component
|
||
|
||
host_id = record.host_id or _load_existing_host_id()
|
||
if host_id is None:
|
||
return False
|
||
base_url = _daemon_base_url(record)
|
||
if base_url is None:
|
||
return False
|
||
result = _host_http_json(
|
||
base_url=base_url,
|
||
method="GET",
|
||
path=f"/v1/hosts/{url_component(host_id)}",
|
||
timeout_s=timeout_s,
|
||
)
|
||
if result.status_code != 200 or not isinstance(result.body, dict):
|
||
return False
|
||
return result.body.get("status") == "online"
|
||
|
||
|
||
def _daemon_registry_dir() -> Path:
|
||
"""
|
||
Return the directory containing per-target daemon registry records.
|
||
|
||
Tests patch :data:`_HOST_PID_PATH`, so derive the registry root from
|
||
the pidfile's parent instead of capturing ``Path.home()`` separately.
|
||
|
||
:returns: Registry directory path, e.g.
|
||
``Path("~/.omnigent/daemons")``.
|
||
"""
|
||
return _HOST_PID_PATH.parent / "daemons"
|
||
|
||
|
||
def _daemon_record_path(target: str) -> Path:
|
||
"""
|
||
Return the registry JSON path for *target*.
|
||
|
||
:param target: Normalized daemon target, e.g.
|
||
``"https://example.databricksapps.com"`` or ``"local"``.
|
||
:returns: JSON registry path for the target.
|
||
"""
|
||
digest = hashlib.sha256(target.encode("utf-8")).hexdigest()[:16]
|
||
return _daemon_registry_dir() / f"{digest}.json"
|
||
|
||
|
||
def _record_from_json(raw: _HostJsonObject) -> _HostDaemonRecord | None:
|
||
"""
|
||
Parse a daemon record from decoded JSON.
|
||
|
||
:param raw: Decoded JSON object, e.g.
|
||
``{"pid": 4242, "target": "local", "mode": "local"}``.
|
||
:returns: Parsed :class:`_HostDaemonRecord`, or ``None`` if the
|
||
record is malformed.
|
||
"""
|
||
try:
|
||
pid_raw = raw["pid"]
|
||
if not isinstance(pid_raw, str | int) or isinstance(pid_raw, bool):
|
||
return None
|
||
pid = int(pid_raw)
|
||
target = str(raw["target"])
|
||
mode = str(raw["mode"])
|
||
started_at_raw = raw["started_at"]
|
||
if not isinstance(started_at_raw, str | int) or isinstance(started_at_raw, bool):
|
||
return None
|
||
started_at = int(started_at_raw)
|
||
except (KeyError, TypeError, ValueError):
|
||
return None
|
||
if mode not in {"local", "server"} or not target:
|
||
return None
|
||
server_url = raw.get("server_url")
|
||
log_path = raw.get("log_path")
|
||
host_id = raw.get("host_id")
|
||
resolved_server_url = raw.get("resolved_server_url")
|
||
config_sig = raw.get("config_sig")
|
||
return _HostDaemonRecord(
|
||
pid=pid,
|
||
target=target,
|
||
mode=mode,
|
||
server_url=server_url if isinstance(server_url, str) and server_url else None,
|
||
log_path=log_path if isinstance(log_path, str) and log_path else None,
|
||
started_at=started_at,
|
||
host_id=host_id if isinstance(host_id, str) and host_id else None,
|
||
resolved_server_url=(
|
||
resolved_server_url
|
||
if isinstance(resolved_server_url, str) and resolved_server_url
|
||
else None
|
||
),
|
||
config_sig=config_sig if isinstance(config_sig, str) and config_sig else None,
|
||
)
|
||
|
||
|
||
def _read_daemon_record(path: Path) -> _HostDaemonRecord | None:
|
||
"""
|
||
Read a daemon registry record from disk.
|
||
|
||
:param path: JSON file path to read, e.g.
|
||
``Path("~/.omnigent/daemons/abc.json")``.
|
||
:returns: Parsed daemon record, or ``None`` if unreadable or malformed.
|
||
"""
|
||
try:
|
||
raw = json.loads(path.read_text())
|
||
except (OSError, json.JSONDecodeError):
|
||
return None
|
||
if not isinstance(raw, dict):
|
||
return None
|
||
return _record_from_json(cast(_HostJsonObject, raw))
|
||
|
||
|
||
def _write_daemon_record(record: _HostDaemonRecord) -> None:
|
||
"""
|
||
Persist a daemon registry record.
|
||
|
||
:param record: Record to write, e.g. a local daemon record with
|
||
``target == "local"``.
|
||
"""
|
||
path = _daemon_record_path(record.target)
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_text(json.dumps(asdict(record), indent=2, sort_keys=True) + "\n")
|
||
|
||
|
||
def _delete_daemon_record(record: _HostDaemonRecord) -> None:
|
||
"""
|
||
Delete a daemon registry record if it exists.
|
||
|
||
Removes the per-target JSON record, and also clears the legacy
|
||
``host.pid`` when it names the same target — otherwise a daemon tracked
|
||
only by the legacy pidfile (no JSON record) leaves a phantom that
|
||
reappears on every subsequent ``stop`` / ``host status``.
|
||
|
||
:param record: Record whose target path should be removed.
|
||
"""
|
||
with contextlib.suppress(OSError):
|
||
_daemon_record_path(record.target).unlink()
|
||
legacy = _read_host_pid_file()
|
||
if legacy is not None and legacy[1] == record.target:
|
||
with contextlib.suppress(OSError):
|
||
_HOST_PID_PATH.unlink()
|
||
|
||
|
||
def _legacy_daemon_record() -> _HostDaemonRecord | None:
|
||
"""
|
||
Build a daemon record from the legacy ``host.pid`` file.
|
||
|
||
:returns: Legacy record, or ``None`` if the pidfile is absent or
|
||
malformed.
|
||
"""
|
||
existing = _read_host_pid_file()
|
||
if existing is None:
|
||
return None
|
||
pid, target = existing
|
||
mode = "local" if target == _LOCAL_DAEMON_MARKER else "server"
|
||
return _HostDaemonRecord(
|
||
pid=pid,
|
||
target=target,
|
||
mode=mode,
|
||
server_url=None if mode == "local" else target,
|
||
log_path=None,
|
||
started_at=0,
|
||
host_id=_load_existing_host_id(),
|
||
)
|
||
|
||
|
||
def _list_daemon_records(*, include_legacy: bool = True) -> list[_HostDaemonRecord]:
|
||
"""
|
||
List daemon registry records.
|
||
|
||
:param include_legacy: When ``True``, include a synthetic record
|
||
from ``host.pid`` if no JSON record exists for that target.
|
||
:returns: Records ordered by ``started_at`` descending.
|
||
"""
|
||
records: dict[str, _HostDaemonRecord] = {}
|
||
registry = _daemon_registry_dir()
|
||
if registry.exists():
|
||
for path in registry.glob("*.json"):
|
||
record = _read_daemon_record(path)
|
||
if record is not None:
|
||
records[record.target] = record
|
||
if include_legacy:
|
||
legacy = _legacy_daemon_record()
|
||
if legacy is not None and legacy.target not in records:
|
||
records[legacy.target] = legacy
|
||
return sorted(records.values(), key=lambda r: r.started_at, reverse=True)
|
||
|
||
|
||
def _find_daemon_record(target: str) -> _HostDaemonRecord | None:
|
||
"""
|
||
Find a daemon record by target.
|
||
|
||
:param target: Normalized daemon target, e.g. ``"local"``.
|
||
:returns: Matching daemon record, or ``None``.
|
||
"""
|
||
for record in _list_daemon_records():
|
||
if record.target == target:
|
||
return record
|
||
return None
|
||
|
||
|
||
def _update_daemon_resolved_server_url(target: str, server_url: str) -> None:
|
||
"""
|
||
Record the concrete Omnigent server URL served by a daemon target.
|
||
|
||
:param target: Normalized target, e.g. ``"local"``.
|
||
:param server_url: Concrete server URL, e.g.
|
||
``"http://127.0.0.1:8123"``.
|
||
"""
|
||
record = _find_daemon_record(target)
|
||
if record is None:
|
||
return
|
||
_write_daemon_record(
|
||
_HostDaemonRecord(
|
||
**{
|
||
**asdict(record),
|
||
"resolved_server_url": server_url.rstrip("/"),
|
||
}
|
||
)
|
||
)
|
||
|
||
|
||
def _load_existing_host_id() -> str | None:
|
||
"""
|
||
Load the existing local host id without creating one.
|
||
|
||
:returns: Host id from config, e.g. ``"host_abc123"``, or ``None``.
|
||
"""
|
||
candidate_paths = [_effective_global_config_path()]
|
||
from omnigent.host.identity import CONFIG_PATH
|
||
|
||
if CONFIG_PATH not in candidate_paths:
|
||
candidate_paths.append(CONFIG_PATH)
|
||
for path in candidate_paths:
|
||
try:
|
||
raw = yaml.safe_load(path.read_text()) if path.exists() else None
|
||
except (OSError, yaml.YAMLError):
|
||
continue
|
||
if not isinstance(raw, dict):
|
||
continue
|
||
host = raw.get("host")
|
||
if isinstance(host, dict):
|
||
host_id = host.get("host_id")
|
||
if isinstance(host_id, str) and host_id:
|
||
return host_id
|
||
return None
|
||
|
||
|
||
def _daemon_tunnel_recovers(
|
||
record: _HostDaemonRecord,
|
||
*,
|
||
grace_s: float = _DAEMON_RECONNECT_GRACE_S,
|
||
) -> bool:
|
||
"""
|
||
Return whether a daemon's host tunnel is (or quickly becomes) online.
|
||
|
||
Probes the host status immediately, then polls for up to *grace_s* to
|
||
let a daemon mid-reconnect (after a transient tunnel drop) re-register
|
||
before we judge it a zombie.
|
||
|
||
:param record: Daemon record to probe.
|
||
:param grace_s: Seconds to keep polling for recovery, e.g. ``5.0``.
|
||
:returns: ``True`` if the host reports online within the grace window.
|
||
"""
|
||
if _daemon_host_online(record):
|
||
return True
|
||
deadline = time.monotonic() + grace_s
|
||
while time.monotonic() < deadline:
|
||
time.sleep(0.5)
|
||
if _daemon_host_online(record):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _daemon_host_identity_changed(record: _HostDaemonRecord) -> bool:
|
||
"""
|
||
Return whether a daemon record belongs to a different current host id.
|
||
|
||
A live daemon can outlast edits to ``~/.omnigent/config.yaml``. Reusing
|
||
that process leaves commands polling for the new host id while the daemon
|
||
is still connected as the old host id, which can never succeed.
|
||
|
||
:param record: Daemon record being considered for reuse.
|
||
:returns: ``True`` when the record has a host id and the current config
|
||
either has a different id or no id.
|
||
"""
|
||
if record.host_id is None:
|
||
return False
|
||
current_host_id = _load_existing_host_id()
|
||
return record.host_id != current_host_id
|
||
|
||
|
||
def _terminate_host_unit(record: _HostDaemonRecord, *, reason: str) -> None:
|
||
"""
|
||
Tear down a daemon and, in local mode, the Omnigent server it owns.
|
||
|
||
The ``--local`` daemon spawns its Omnigent server once and never respawns
|
||
it, so a stale daemon and its server must be replaced as a unit:
|
||
killing only the daemon would strand the server (and vice versa). This
|
||
stops both so the caller can spawn a fresh, correctly-configured pair.
|
||
|
||
:param record: Daemon record to tear down.
|
||
:param reason: Human-readable reason surfaced to the user, e.g.
|
||
``"config changed (auth)"`` or ``"host tunnel is offline"``.
|
||
:returns: None.
|
||
"""
|
||
click.echo(f"Restarting host daemon for {record.target!r} ({reason}).", err=True)
|
||
# Best-effort: a daemon that refuses to die shouldn't hard-fail the
|
||
# run — the fresh daemon's record overwrites this one regardless.
|
||
with contextlib.suppress(click.ClickException):
|
||
_terminate_daemon(record, force=True)
|
||
if record.mode == "local":
|
||
stop_local_omnigent_server()
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _DaemonReuseDecision:
|
||
"""Outcome of evaluating whether an existing daemon can be reused.
|
||
|
||
:param reuse: ``True`` when the existing daemon is live, config-matching,
|
||
and tunnel-healthy, so the caller should NOT spawn a new one.
|
||
:param config_changed: ``True`` when the existing daemon was torn down
|
||
specifically because its config signature no longer matches this
|
||
invocation (e.g. the user flipped ``OMNIGENT_AUTH_ENABLED``).
|
||
Distinct from a transparent tunnel-health heal — only a config
|
||
change forces the caller to ask the user to re-run, because the
|
||
server was restarted into a different auth posture mid-command.
|
||
"""
|
||
|
||
reuse: bool
|
||
config_changed: bool
|
||
|
||
|
||
def _reuse_existing_daemon_record(target: str) -> _DaemonReuseDecision:
|
||
"""
|
||
Decide whether an existing daemon for *target* can be reused.
|
||
|
||
Reuse requires more than a live PID: a daemon whose process is alive
|
||
but whose server tunnel is down (server restart, ungraceful death,
|
||
flapping tunnel) is a zombie — the host reads ``offline`` and the
|
||
caller would poll until timeout. And a daemon spawned under a
|
||
different server config (e.g. the user flipped
|
||
``OMNIGENT_AUTH_ENABLED``) would silently keep its old auth
|
||
mode. In both cases we tear the unit down here and return
|
||
``reuse=False`` so the caller spawns a fresh one — flagging
|
||
``config_changed`` for the auth-drift case so the caller can ask the
|
||
user to re-run against the freshly-restarted server.
|
||
|
||
Self-healing is limited to daemons this CLI spawned in the background
|
||
(they carry a ``log_path``). Foreground ``host`` daemons
|
||
(``log_path is None``) and legacy records (``config_sig is None``) are
|
||
never silently killed — we don't tear down an interactive process or
|
||
one whose config we can't verify.
|
||
|
||
:param target: Normalized daemon target, e.g. ``"local"``.
|
||
:returns: A :class:`_DaemonReuseDecision`.
|
||
"""
|
||
existing = _find_daemon_record(target)
|
||
if existing is None:
|
||
return _DaemonReuseDecision(reuse=False, config_changed=False)
|
||
if not _pid_alive(existing.pid):
|
||
_delete_daemon_record(existing)
|
||
return _DaemonReuseDecision(reuse=False, config_changed=False)
|
||
|
||
background = existing.log_path is not None
|
||
if background and _daemon_host_identity_changed(existing):
|
||
_terminate_host_unit(existing, reason="host identity changed")
|
||
return _DaemonReuseDecision(reuse=False, config_changed=False)
|
||
|
||
if target != _LOCAL_DAEMON_MARKER:
|
||
# Remote / explicit ``--server`` mode: the daemon connects to a server
|
||
# we don't own and can't restart, so the config-signature / heal /
|
||
# "re-run" semantics below don't apply (auth posture is the remote's
|
||
# concern; its own reconnect loop covers transient tunnel drops). Keep
|
||
# the original PID-liveness reuse so a live daemon for the URL is
|
||
# reused as-is.
|
||
return _DaemonReuseDecision(reuse=True, config_changed=False)
|
||
|
||
if not background:
|
||
# Foreground host / legacy host.pid: keep prior behavior — a
|
||
# live PID is reused as-is (don't kill the user's interactive
|
||
# process or guess at an unstamped config).
|
||
return _DaemonReuseDecision(reuse=True, config_changed=False)
|
||
|
||
# Config drift → the running server has the wrong auth source.
|
||
desired_sig = server_config_signature()
|
||
if existing.config_sig is not None and existing.config_sig != desired_sig:
|
||
_terminate_host_unit(existing, reason="config changed (auth)")
|
||
return _DaemonReuseDecision(reuse=False, config_changed=True)
|
||
|
||
# Tunnel health → don't reuse a zombie. Skip very young daemons (a
|
||
# concurrent invocation may have just spawned one still connecting). This
|
||
# is a transparent heal, NOT a config change — the caller continues.
|
||
age_s = time.time() - existing.started_at
|
||
if age_s >= _DAEMON_REUSE_MIN_AGE_S and not _daemon_tunnel_recovers(existing):
|
||
_terminate_host_unit(existing, reason="host tunnel is offline")
|
||
return _DaemonReuseDecision(reuse=False, config_changed=False)
|
||
return _DaemonReuseDecision(reuse=True, config_changed=False)
|
||
|
||
|
||
def _local_daemon_serves_target(target: str, server_url: str | None) -> bool:
|
||
"""
|
||
Check whether the local daemon already serves a requested URL target.
|
||
|
||
:param target: Normalized daemon target, e.g.
|
||
``"http://127.0.0.1:8123"``.
|
||
:param server_url: Requested server URL, or ``None`` for local mode.
|
||
:returns: ``True`` if the live local daemon already serves *target*.
|
||
"""
|
||
if not server_url:
|
||
return False
|
||
local_record = _find_daemon_record(_LOCAL_DAEMON_MARKER)
|
||
if local_record is None or not _pid_alive(local_record.pid):
|
||
return False
|
||
local_url = local_server_url_if_healthy()
|
||
return local_url is not None and local_url.rstrip("/") == target
|
||
|
||
|
||
def _spawn_host_daemon_process(
|
||
*,
|
||
args: list[str],
|
||
env: dict[str, str],
|
||
) -> _SpawnedDaemonProcess | None:
|
||
"""
|
||
Spawn the background host daemon and attach its log file.
|
||
|
||
:param args: Process argv, e.g. ``["python", "-m", "..."]``.
|
||
:param env: Allowlisted daemon environment.
|
||
:returns: Spawned process metadata, or ``None`` if spawn fails.
|
||
"""
|
||
log_dir = _HOST_PID_PATH.parent / "logs" / "host-daemon"
|
||
log_dir.mkdir(parents=True, exist_ok=True)
|
||
log_fd, log_path = tempfile.mkstemp(prefix="daemon-", suffix=".log", dir=log_dir)
|
||
log_fh = os.fdopen(log_fd, "wb")
|
||
try:
|
||
proc = subprocess.Popen(
|
||
args,
|
||
env=env,
|
||
stdout=log_fh,
|
||
stderr=log_fh,
|
||
**_proc.spawn_kwargs(),
|
||
)
|
||
except OSError:
|
||
return None
|
||
finally:
|
||
log_fh.close()
|
||
return _SpawnedDaemonProcess(pid=proc.pid, log_path=log_path)
|
||
|
||
|
||
def _persist_spawned_daemon(
|
||
*,
|
||
target: str,
|
||
spawned: _SpawnedDaemonProcess,
|
||
config_sig: str,
|
||
) -> None:
|
||
"""
|
||
Persist registry and legacy pidfile entries for a spawned daemon.
|
||
|
||
:param target: Normalized daemon target, e.g. ``"local"``.
|
||
:param spawned: Spawned process metadata.
|
||
:param config_sig: Config signature this daemon was spawned under,
|
||
e.g. ``"3f9a1c2b4d5e6f70"`` (see :func:`server_config_signature`).
|
||
"""
|
||
mode = "local" if target == _LOCAL_DAEMON_MARKER else "server"
|
||
_write_daemon_record(
|
||
_HostDaemonRecord(
|
||
pid=spawned.pid,
|
||
target=target,
|
||
mode=mode,
|
||
server_url=None if mode == "local" else target,
|
||
log_path=spawned.log_path,
|
||
started_at=int(time.time()),
|
||
host_id=_load_existing_host_id(),
|
||
config_sig=config_sig,
|
||
)
|
||
)
|
||
_HOST_PID_PATH.write_text(f"{spawned.pid}\n{target}\n")
|
||
|
||
|
||
def _foreground_daemon_record(
|
||
*,
|
||
target: str,
|
||
server_url: str,
|
||
host_id: str | None,
|
||
) -> _HostDaemonRecord:
|
||
"""
|
||
Build the registry record for the current foreground host process.
|
||
|
||
:param target: Normalized daemon target, e.g.
|
||
``"https://example.databricksapps.com"`` or ``"local"``.
|
||
:param server_url: Concrete Omnigent server URL being connected to, e.g.
|
||
``"http://127.0.0.1:8123"``.
|
||
:param host_id: Local host id, e.g. ``"host_abc123"``.
|
||
:returns: Daemon registry record for ``os.getpid()``.
|
||
"""
|
||
mode = "local" if target == _LOCAL_DAEMON_MARKER else "server"
|
||
return _HostDaemonRecord(
|
||
pid=os.getpid(),
|
||
target=target,
|
||
mode=mode,
|
||
server_url=None if mode == "local" else target,
|
||
log_path=None,
|
||
started_at=int(time.time()),
|
||
host_id=host_id,
|
||
resolved_server_url=server_url.rstrip("/") if mode == "local" else None,
|
||
config_sig=server_config_signature(),
|
||
)
|
||
|
||
|
||
def _live_daemon_conflict(record: _HostDaemonRecord) -> _HostDaemonRecord | None:
|
||
"""
|
||
Find a live daemon that already serves a foreground record target.
|
||
|
||
:param record: Foreground daemon record this process wants to claim.
|
||
:returns: Conflicting live record, or ``None``.
|
||
"""
|
||
existing = _find_daemon_record(record.target)
|
||
if existing is not None and existing.pid != record.pid and _pid_alive(existing.pid):
|
||
return existing
|
||
if record.mode == "server" and record.server_url is not None:
|
||
local_record = _find_daemon_record(_LOCAL_DAEMON_MARKER)
|
||
if (
|
||
local_record is not None
|
||
and local_record.pid != record.pid
|
||
and _pid_alive(local_record.pid)
|
||
and local_record.resolved_server_url == record.server_url.rstrip("/")
|
||
):
|
||
return local_record
|
||
return None
|
||
|
||
|
||
def _claim_foreground_daemon_record(
|
||
record: _HostDaemonRecord,
|
||
) -> _HostDaemonRecord | None:
|
||
"""
|
||
Persist a foreground daemon record unless a live duplicate exists.
|
||
|
||
:param record: Foreground process record, e.g. one with
|
||
``pid == os.getpid()``.
|
||
:returns: Previous record for the same target, or ``None``.
|
||
:raises click.ClickException: If a live daemon already serves the
|
||
same target.
|
||
"""
|
||
conflict = _live_daemon_conflict(record)
|
||
if conflict is not None:
|
||
raise click.ClickException(
|
||
"A host daemon is already running for this server "
|
||
f"(pid={conflict.pid}, target={conflict.target}). "
|
||
"Run `omnigent host status` to inspect it or "
|
||
"`omnigent host stop --server ...` to stop it first."
|
||
)
|
||
previous = _find_daemon_record(record.target)
|
||
if previous is not None and not _pid_alive(previous.pid):
|
||
_delete_daemon_record(previous)
|
||
previous = None
|
||
_write_daemon_record(record)
|
||
return previous
|
||
|
||
|
||
def _restore_replaced_daemon_record(
|
||
record: _HostDaemonRecord,
|
||
previous: _HostDaemonRecord | None,
|
||
) -> None:
|
||
"""
|
||
Restore the record replaced by a foreground host process.
|
||
|
||
If another process has already written a newer record for the same
|
||
target, this function leaves it untouched.
|
||
|
||
:param record: Foreground daemon record written by this process.
|
||
:param previous: Previous record returned by
|
||
:func:`_claim_foreground_daemon_record`, or ``None``.
|
||
"""
|
||
current = _read_daemon_record(_daemon_record_path(record.target))
|
||
if current is None:
|
||
return
|
||
if current.pid != record.pid or current.started_at != record.started_at:
|
||
return
|
||
if previous is None:
|
||
_delete_daemon_record(record)
|
||
return
|
||
_write_daemon_record(previous)
|
||
|
||
|
||
def _load_or_create_host_id() -> str | None:
|
||
"""
|
||
Load or create the host id used by a foreground host process.
|
||
|
||
:returns: Host id from local config, e.g. ``"host_abc123"``, or
|
||
``None`` if the identity file cannot be created.
|
||
"""
|
||
host_id = _load_existing_host_id()
|
||
if host_id is not None:
|
||
return host_id
|
||
from omnigent.host.identity import CONFIG_PATH, load_or_create_host_identity
|
||
|
||
try:
|
||
return load_or_create_host_identity(CONFIG_PATH).host_id
|
||
except OSError:
|
||
return None
|
||
|
||
|
||
def _ensure_host_daemon(server_url: str | None) -> bool:
|
||
"""Start or reuse a host daemon for one target.
|
||
|
||
:param server_url: Omnigent server URL the daemon connects to, or ``None``
|
||
for local mode — the daemon starts (or reuses) a persistent local
|
||
Omnigent server and connects to that.
|
||
:returns: ``True`` when an existing daemon was torn down and respawned
|
||
because its config (auth source) changed — the caller
|
||
should ask the user to re-run against the freshly-restarted server
|
||
rather than continue this command mid-restart. ``False`` for a
|
||
plain reuse, a transparent tunnel-health heal, or a first spawn.
|
||
"""
|
||
target = _normalize_daemon_target(server_url)
|
||
decision = _reuse_existing_daemon_record(target)
|
||
if decision.reuse:
|
||
return False
|
||
if not decision.config_changed and _local_daemon_serves_target(target, server_url):
|
||
return False
|
||
|
||
_HOST_PID_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||
mode_args = ["--local"] if not server_url else ["--server", server_url]
|
||
args = [sys.executable, "-m", "omnigent.host._daemon_entry", *mode_args]
|
||
spawned = _spawn_host_daemon_process(
|
||
args=args, env=_build_host_daemon_env(server_url=server_url)
|
||
)
|
||
if spawned is None:
|
||
return False
|
||
_persist_spawned_daemon(
|
||
target=target,
|
||
spawned=spawned,
|
||
config_sig=server_config_signature(),
|
||
)
|
||
return decision.config_changed
|
||
|
||
|
||
def _build_host_daemon_env(
|
||
*,
|
||
server_url: str | None,
|
||
) -> dict[str, str]:
|
||
"""
|
||
Build the environment for the background host daemon.
|
||
|
||
Remote daemons connect to an already-running Omnigent server, so they only
|
||
need process essentials, TLS trust, and Databricks auth. Local daemons
|
||
also start the local Omnigent server; that server is the user's local runtime
|
||
and must inherit Omnigent config plus provider credentials such as
|
||
``OPENAI_API_KEY`` and ``OPENAI_BASE_URL``. Both modes are allowlisted:
|
||
local mode carries the runtime/provider vars needed by the local server,
|
||
but unrelated shell secrets are not inherited merely because the daemon
|
||
runs on the user's machine. Runners launched by the daemon still pass
|
||
through :func:`omnigent.host.connect._build_runner_env`, so these
|
||
local-server credentials do not leak into runner subprocesses.
|
||
|
||
:param server_url: Omnigent server URL for remote mode, e.g.
|
||
``"https://example.databricksapps.com"``, or a falsey value
|
||
such as ``None`` / ``""`` for local daemon mode.
|
||
:returns: Environment dict for ``subprocess.Popen``.
|
||
"""
|
||
from omnigent.host.connect import (
|
||
_RUNNER_ENV_ALLOWLIST,
|
||
_RUNNER_ENV_ALLOWLIST_PREFIXES,
|
||
)
|
||
|
||
if not server_url:
|
||
daemon_env_prefixes = (*_RUNNER_ENV_ALLOWLIST_PREFIXES, *_LOCAL_DAEMON_ENV_PREFIXES)
|
||
env = {
|
||
key: value
|
||
for key, value in os.environ.items()
|
||
if key in _RUNNER_ENV_ALLOWLIST
|
||
or key in _LOCAL_DAEMON_ENV_ALLOWLIST
|
||
or key.startswith(daemon_env_prefixes)
|
||
}
|
||
else:
|
||
# Allowlist the remote daemon's environment (W8): pass process
|
||
# essentials + TLS trust + the user's Databricks auth (the daemon
|
||
# authenticates to the server with it), but not unrelated provider
|
||
# secrets like ANTHROPIC_API_KEY / OPENAI_API_KEY.
|
||
daemon_env_prefixes = (*_RUNNER_ENV_ALLOWLIST_PREFIXES, "DATABRICKS_")
|
||
env = {
|
||
key: value
|
||
for key, value in os.environ.items()
|
||
if key in _RUNNER_ENV_ALLOWLIST or key.startswith(daemon_env_prefixes)
|
||
}
|
||
return env
|
||
|
||
|
||
def _read_host_pid_file() -> tuple[int, str] | None:
|
||
"""Read the host daemon PID file (two lines: PID and server URL).
|
||
|
||
:returns: ``(pid, server_url)`` if well-formed, ``None`` otherwise.
|
||
"""
|
||
if not _HOST_PID_PATH.exists():
|
||
return None
|
||
try:
|
||
lines = _HOST_PID_PATH.read_text().strip().splitlines()
|
||
if len(lines) < 2:
|
||
return None
|
||
return int(lines[0]), lines[1]
|
||
except (ValueError, OSError):
|
||
return None
|
||
|
||
|
||
def _host_daemon_alive() -> bool:
|
||
"""Check whether the local-mode host daemon is still alive.
|
||
|
||
:returns: ``True`` if a local daemon record exists and its process
|
||
is running.
|
||
"""
|
||
existing = _find_daemon_record(_LOCAL_DAEMON_MARKER)
|
||
if existing is None:
|
||
return False
|
||
return _pid_alive(existing.pid)
|
||
|
||
|
||
# Generous because a port-contended spawn boots TWICE: the bind-race loser
|
||
# runs to its natural EADDRINUSE exit (completing DB migrations) before the
|
||
# free-port respawn cold-boots — see ensure_local_omnigent_server.
|
||
_LOCAL_SERVER_DISCOVER_TIMEOUT_S = 120.0
|
||
|
||
|
||
def _ensure_databricks_server_auth(server: str, *, non_interactive: bool = False) -> None:
|
||
"""Sign in (or fail with the login hint) for Databricks-fronted servers.
|
||
|
||
Probes ``/v1/me`` with whatever credentials the auth chain can mint
|
||
today. A non-200 answer that carries the Databricks edge signature
|
||
(302 to the workspace OAuth page, or a DatabricksRealm 401) means
|
||
the run would otherwise die much later with an opaque "non-JSON
|
||
response (status=302)" traceback from the session-create call. On a
|
||
TTY we run the same flow ``omnigent login`` would and continue;
|
||
headless invocations get the exact command to run instead.
|
||
|
||
Non-Databricks postures are deliberately left alone: local accounts
|
||
servers auto-authenticate downstream (magic-link redeem), and
|
||
header-mode servers answer 200 outright.
|
||
|
||
:param server: Remote server base URL without a trailing slash,
|
||
e.g. ``"https://myapp-123.aws.databricksapps.com"``.
|
||
:param non_interactive: When ``True``, never run the browser login —
|
||
emit the same fail-loud hint a headless invocation gets, even on a
|
||
TTY. Lets callers (e.g. ``omnigent host --non-interactive``) keep
|
||
their scripted, no-prompt behavior.
|
||
:raises click.ClickException: When the server is Databricks-fronted,
|
||
no credentials resolve, and the login flow is suppressed (stdin is
|
||
not a TTY or ``non_interactive`` is set) — or the login flow itself
|
||
fails.
|
||
"""
|
||
import httpx as _httpx
|
||
|
||
from omnigent.chat import _remote_headers
|
||
|
||
try:
|
||
probe = _httpx.get(
|
||
f"{server}/v1/me",
|
||
headers=_remote_headers(server_url=server),
|
||
timeout=10.0,
|
||
)
|
||
except _httpx.HTTPError:
|
||
# Unreachable / transient: let the connect path raise its own,
|
||
# already-actionable error rather than failing the pre-flight.
|
||
return
|
||
if probe.status_code == 200:
|
||
return
|
||
workspace_host = _databricks_workspace_login_target(server, probe)
|
||
if workspace_host is None:
|
||
return
|
||
login_cmd = f"omnigent login {server}"
|
||
if non_interactive or not sys.stdin.isatty():
|
||
raise click.ClickException(
|
||
f"Not signed in to {server} (Databricks-fronted; /v1/me answered "
|
||
f"HTTP {probe.status_code}). Run `{login_cmd}` and retry."
|
||
)
|
||
click.echo(f"Not signed in to {server} — running `{login_cmd}` first.")
|
||
# Recover the ``?o=`` selector from a prior login record so a re-login
|
||
# still targets the right workspace.
|
||
from omnigent.cli_auth import load_databricks_org_id
|
||
|
||
_databricks_login(server, workspace_host, org_id=load_databricks_org_id(server))
|
||
|
||
|
||
def _ensure_backend(server: str | None) -> str:
|
||
"""Ensure the host daemon is running and return the Omnigent server URL.
|
||
|
||
The daemon is the single backend for ``attach`` / ``run`` / ``claude`` /
|
||
``codex``: it spawns the runner and, in local mode, the Omnigent server too.
|
||
The CLI is a pure client of the returned URL.
|
||
|
||
:param server: ``--server`` value after config fallback. A non-empty
|
||
value targets that (remote or explicit-local) server. ``None`` or
|
||
``""`` selects local mode: the daemon starts (or reuses) a
|
||
persistent local Omnigent server and this returns its discovered loopback
|
||
URL.
|
||
:returns: A concrete base URL, e.g. ``"http://127.0.0.1:8123"`` or the
|
||
remote URL passed in.
|
||
:raises click.ClickException: If local mode's server never becomes
|
||
reachable.
|
||
"""
|
||
from omnigent._runner_startup import (
|
||
STARTUP_PHASE_CONNECTING_REMOTE,
|
||
STARTUP_PHASE_LOCAL_SERVER,
|
||
STARTUP_PHASE_STARTING,
|
||
runner_startup_progress,
|
||
)
|
||
|
||
if server:
|
||
# Remote / explicit-server mode: the server isn't ours to restart, so
|
||
# there's no auth-mode-flip "re-run" to surface (config_changed is
|
||
# always False for a non-local target). Expand a bare workspace URL
|
||
# to its /api/2.0/omnigent mount, then sign in first when the
|
||
# server is Databricks-fronted and we hold no usable credentials —
|
||
# otherwise the session-create call deep in the REPL bring-up
|
||
# surfaces the edge redirect as an opaque non-JSON-response
|
||
# traceback.
|
||
server = _resolve_server_url(server)
|
||
_ensure_databricks_server_auth(server)
|
||
with runner_startup_progress(initial_message=STARTUP_PHASE_CONNECTING_REMOTE):
|
||
_ensure_host_daemon(server)
|
||
return server
|
||
# Local mode: the daemon spawns (or reuses) a persistent local Omnigent server.
|
||
# On a cold start this is the longest silent gap between the user pressing
|
||
# Enter and any output, so render a spinner whose label tracks the step.
|
||
# It clears on context exit — before any auth-mode-change echo below and
|
||
# before the REPL/terminal the caller brings up — and falls back to plain
|
||
# stderr lines off a TTY (CI, daemon logfiles).
|
||
with runner_startup_progress(initial_message=STARTUP_PHASE_STARTING) as progress:
|
||
config_changed = _ensure_host_daemon(None)
|
||
progress.update(STARTUP_PHASE_LOCAL_SERVER)
|
||
local_url = _discover_local_server_url()
|
||
_update_daemon_resolved_server_url(_LOCAL_DAEMON_MARKER, local_url)
|
||
if config_changed:
|
||
_exit_for_auth_mode_change(local_url)
|
||
return local_url
|
||
|
||
|
||
def _exit_for_auth_mode_change(base_url: str) -> None:
|
||
"""Tell the user the server was restarted in a new mode, then exit clean.
|
||
|
||
The local Omnigent server bakes its auth posture (header vs accounts, cookie
|
||
secret) at boot, so an ``OMNIGENT_AUTH_ENABLED`` flip restarts it
|
||
via :func:`_ensure_host_daemon`. Continuing the *same* command across
|
||
that restart is brittle — the in-flight session/credential/terminal
|
||
bring-up straddles two server identities. Instead we stop here with a
|
||
clear, actionable message and exit 0, so the next ``omnigent run`` is
|
||
a clean single-mode start. When the new mode is accounts and no admin
|
||
exists yet, point the user at the one-time setup URL.
|
||
|
||
:param base_url: The freshly-restarted Omnigent server URL, e.g.
|
||
``"http://127.0.0.1:6767"``.
|
||
:returns: Never returns — raises ``SystemExit(0)``.
|
||
:raises SystemExit: Always, with code 0 (a clean, expected stop).
|
||
"""
|
||
needs_admin_setup = False
|
||
result = _host_http_json(base_url=base_url, method="GET", path="/v1/info")
|
||
if result.status_code == 200 and isinstance(result.body, dict):
|
||
needs_admin_setup = bool(
|
||
result.body.get("accounts_enabled") and result.body.get("needs_setup")
|
||
)
|
||
|
||
click.echo("", err=True)
|
||
click.echo(" ✓ Auth mode changed — the local server was restarted to match.", err=True)
|
||
if needs_admin_setup:
|
||
click.echo(
|
||
f" Create your one-time admin account at {base_url.rstrip('/')} "
|
||
"(it may have opened automatically),",
|
||
err=True,
|
||
)
|
||
click.echo(" then re-run `omnigent run` to start.", err=True)
|
||
else:
|
||
click.echo(" Re-run `omnigent run` to start.", err=True)
|
||
click.echo("", err=True)
|
||
raise SystemExit(0)
|
||
|
||
|
||
def _discover_local_server_url(
|
||
timeout: float = _LOCAL_SERVER_DISCOVER_TIMEOUT_S,
|
||
) -> str:
|
||
"""Poll until the daemon-started local Omnigent server is reachable.
|
||
|
||
In local mode the daemon owns the Omnigent server; the CLI discovers its URL
|
||
via the local-server pidfile + ``/health`` rather than starting it
|
||
itself.
|
||
|
||
:param timeout: Max seconds to wait, e.g. ``60.0``.
|
||
:returns: The loopback server URL, e.g. ``"http://127.0.0.1:8123"``.
|
||
:raises click.ClickException: If the daemon exits first, or the server
|
||
does not come up within the timeout.
|
||
"""
|
||
import time
|
||
|
||
deadline = time.monotonic() + timeout
|
||
while time.monotonic() < deadline:
|
||
url = local_server_url_if_healthy()
|
||
if url is not None:
|
||
return url
|
||
if not _host_daemon_alive():
|
||
raise click.ClickException(
|
||
"The local daemon exited before its Omnigent server became ready. "
|
||
"See logs under ~/.omnigent/logs/host-daemon/ and "
|
||
"~/.omnigent/logs/server/."
|
||
)
|
||
time.sleep(0.2)
|
||
raise click.ClickException(
|
||
f"Timed out after {timeout:.0f}s waiting for the local Omnigent server to "
|
||
"start. See ~/.omnigent/logs/server/ for details."
|
||
)
|
||
|
||
|
||
@dataclass
|
||
class _CliRunnerProcess:
|
||
"""Runner subprocess metadata for the ``omnigent server`` command.
|
||
|
||
:param proc: Runner subprocess handle.
|
||
:param runner_id: Runner id used for the WS tunnel, e.g.
|
||
``"runner_0123456789abcdef"``.
|
||
:param tunnel_token: Secret token that binds the tunnel to
|
||
``runner_id``, e.g. ``"uA6Zz..."``.
|
||
"""
|
||
|
||
proc: subprocess.Popen[bytes]
|
||
runner_id: str
|
||
tunnel_token: str
|
||
log_path: Path | None = None
|
||
|
||
|
||
def _start_cli_runner_process(
|
||
*,
|
||
server_url: str,
|
||
tunnel_token: str | None = None,
|
||
runner_id: str | None = None,
|
||
workspace_cwd: str | Path | None = None,
|
||
capture_logs: bool = False,
|
||
log_dir: str | Path | None = None,
|
||
prewarm_spec_path: str | Path | None = None,
|
||
isolate_session: bool = False,
|
||
extra_env: dict[str, str] | None = None,
|
||
) -> _CliRunnerProcess:
|
||
"""Start the out-of-process runner used by CLI server flows.
|
||
|
||
The runner always connects back over the WebSocket tunnel. Local
|
||
``omnigent server`` passes its loopback URL; ``run --server``
|
||
passes the remote Omnigent server URL.
|
||
|
||
For remote Databricks-fronted servers, the runner subprocess
|
||
authenticates via the stored ``omnigent login`` record (or
|
||
ambient Databricks SDK credentials). Tokens are refreshed
|
||
transparently on each WebSocket reconnect and HTTP callback —
|
||
no static token is passed via environment variable.
|
||
|
||
:param server_url: Server base URL, e.g.
|
||
``"http://127.0.0.1:6767"``.
|
||
:param tunnel_token: Optional binding token for the runner id,
|
||
e.g. ``"uA6Zz..."``. ``None`` generates a fresh token.
|
||
:param runner_id: Optional runner id to advertise. ``None``
|
||
uses a per-run token-bound id for authenticated remote
|
||
servers, or the stable runner id from
|
||
:func:`omnigent.runner.identity.get_stable_runner_id`
|
||
for unauthenticated local servers.
|
||
:param workspace_cwd: Optional local workspace root to expose
|
||
to runner-local filesystem tools when a spec uses the
|
||
placeholder cwd ``"."``. Remote ``run/attach --server``
|
||
passes the CLI launch cwd so local runner tools operate
|
||
in the user's project checkout.
|
||
:param capture_logs: When True, redirect the runner
|
||
subprocess's stdout/stderr to a per-run temp log file
|
||
instead of inheriting the parent's stdio. The attach-remote
|
||
flow sets this so runner WARNINGs (e.g. expected
|
||
tunnel-dispatch failures like sandbox-unsupported)
|
||
don't paint onto the REPL terminal.
|
||
:param log_dir: Optional base log directory to use when
|
||
``capture_logs`` is true. Defaults to the shared
|
||
``~/.omnigent/logs`` location; tests should pass a
|
||
temporary directory to avoid writing to the developer's
|
||
real home.
|
||
:param prewarm_spec_path: Optional YAML path; the runner registers
|
||
its MCP routing metadata during startup without opening transports.
|
||
:param isolate_session: ``True`` for shared-host runners;
|
||
enables per-session workspace isolation so each
|
||
session gets its own subdirectory. ``False`` (default)
|
||
lets the agent see the project root directly.
|
||
:param extra_env: Optional mapping of additional environment
|
||
variables overlaid on top of ``os.environ`` for the runner
|
||
subprocess. Used by tests to route the runner at a mock LLM
|
||
server instead of the ambient API endpoint.
|
||
:returns: The spawned runner process metadata.
|
||
:raises click.ClickException: If the runner exits immediately.
|
||
"""
|
||
from omnigent.runner.identity import (
|
||
RUNNER_ID_ENV_VAR,
|
||
RUNNER_ISOLATE_SESSION_ENV_VAR,
|
||
RUNNER_PARENT_PID_ENV_VAR,
|
||
RUNNER_TUNNEL_BINDING_TOKEN_ENV_VAR,
|
||
RUNNER_WORKSPACE_ENV_VAR,
|
||
token_bound_runner_id,
|
||
)
|
||
|
||
binding_token = tunnel_token.strip() if tunnel_token is not None else None
|
||
if tunnel_token is not None and not binding_token:
|
||
raise click.ClickException("Runner tunnel binding token must not be empty")
|
||
binding_token = binding_token or secrets.token_urlsafe(32)
|
||
resolved_runner_id = runner_id.strip() if runner_id is not None else None
|
||
if runner_id is not None and not resolved_runner_id:
|
||
raise click.ClickException("Runner id must not be empty")
|
||
if resolved_runner_id is None:
|
||
# The runner sends the binding token in the tunnel header;
|
||
# the server derives expected_runner_id from it via
|
||
# token_bound_runner_id(). The path runner_id must match,
|
||
# so we always derive from the binding token — not the
|
||
# stable runner id, which is unrelated to the token.
|
||
resolved_runner_id = token_bound_runner_id(binding_token)
|
||
env = {
|
||
**os.environ,
|
||
**(extra_env or {}),
|
||
"RUNNER_SERVER_URL": server_url,
|
||
RUNNER_ID_ENV_VAR: resolved_runner_id,
|
||
RUNNER_PARENT_PID_ENV_VAR: str(os.getpid()),
|
||
}
|
||
env[RUNNER_TUNNEL_BINDING_TOKEN_ENV_VAR] = binding_token
|
||
if workspace_cwd is not None:
|
||
env[RUNNER_WORKSPACE_ENV_VAR] = str(Path(workspace_cwd).expanduser().resolve())
|
||
if isolate_session:
|
||
env[RUNNER_ISOLATE_SESSION_ENV_VAR] = "1"
|
||
if prewarm_spec_path is not None:
|
||
env["RUNNER_PREWARM_SPEC_PATH"] = str(Path(prewarm_spec_path).expanduser().resolve())
|
||
|
||
log_path: Path | None = None
|
||
log_fh: BinaryIO | None = None
|
||
if capture_logs:
|
||
base_log_dir = (
|
||
Path(log_dir).expanduser()
|
||
if log_dir is not None
|
||
else Path.home() / ".omnigent" / "logs"
|
||
)
|
||
runner_log_dir = base_log_dir / "runner"
|
||
runner_log_dir.mkdir(parents=True, exist_ok=True)
|
||
log_fd, log_name = tempfile.mkstemp(prefix="runner-", suffix=".log", dir=runner_log_dir)
|
||
log_path = Path(log_name)
|
||
log_fh = os.fdopen(log_fd, "wb")
|
||
try:
|
||
runner_proc = subprocess.Popen(
|
||
[sys.executable, "-m", "omnigent.runner._entry"],
|
||
env=env,
|
||
stdout=log_fh,
|
||
stderr=log_fh,
|
||
**_proc.spawn_kwargs(),
|
||
)
|
||
finally:
|
||
if log_fh is not None:
|
||
log_fh.close()
|
||
if runner_proc.poll() is not None:
|
||
from omnigent._runner_startup import format_runner_log_tail
|
||
|
||
raise click.ClickException(
|
||
f"Runner process exited early with code {runner_proc.returncode}."
|
||
f"{format_runner_log_tail(log_path)}"
|
||
)
|
||
return _CliRunnerProcess(
|
||
proc=runner_proc,
|
||
runner_id=resolved_runner_id,
|
||
tunnel_token=binding_token,
|
||
log_path=log_path,
|
||
)
|
||
|
||
|
||
def _stop_cli_runner_process(
|
||
proc: subprocess.Popen[bytes],
|
||
*,
|
||
grace_timeout: float = 5.0,
|
||
) -> None:
|
||
"""Stop a runner subprocess started by :func:`_start_cli_runner_process`.
|
||
|
||
:param proc: Runner subprocess handle to terminate.
|
||
:param grace_timeout: Seconds to wait after SIGTERM before
|
||
sending SIGKILL, e.g. ``5.0``.
|
||
:returns: None.
|
||
"""
|
||
if proc.poll() is None:
|
||
proc.terminate()
|
||
try:
|
||
proc.wait(timeout=grace_timeout)
|
||
except subprocess.TimeoutExpired:
|
||
proc.kill()
|
||
proc.wait()
|
||
|
||
|
||
def _adopt_cli_runner_process(proc: subprocess.Popen[bytes]) -> None:
|
||
"""Detach a runner from this CLI so it keeps running after CLI exit.
|
||
|
||
Sends :data:`RUNNER_ADOPT_SIGNAL` (SIGUSR1, when available) so the
|
||
runner cancels its parent-pid watchdog and survives the launching
|
||
CLI's exit. Used when the user detaches from tmux: Claude and the
|
||
runner stay alive and the web UI stays connected. A no-op if the
|
||
runner has already exited, or if the platform has no adopt signal.
|
||
|
||
:param proc: Runner subprocess handle to adopt.
|
||
:returns: None.
|
||
"""
|
||
from omnigent.runner.identity import RUNNER_ADOPT_SIGNAL
|
||
|
||
if RUNNER_ADOPT_SIGNAL is None:
|
||
return
|
||
if proc.poll() is None:
|
||
with contextlib.suppress(ProcessLookupError):
|
||
proc.send_signal(RUNNER_ADOPT_SIGNAL)
|
||
|
||
|
||
def _assert_server_port_bindable(host: str, port: int) -> None:
|
||
"""
|
||
Fail before app startup when the requested TCP listener cannot bind.
|
||
|
||
Mirrors uvicorn's TCP bind shape closely enough for CLI preflight:
|
||
IPv6 is selected when the host contains ``":"``, and
|
||
``SO_REUSEADDR`` is set before bind. This is intentionally a bind
|
||
probe, not a connect probe, so a failed client connection to the
|
||
port does not make us report the port as occupied.
|
||
|
||
:param host: Interface to bind, e.g. ``"127.0.0.1"``.
|
||
:param port: TCP port to bind, e.g. ``6767``.
|
||
:returns: None.
|
||
:raises click.ClickException: If the host/port cannot be bound.
|
||
"""
|
||
import socket
|
||
|
||
family = socket.AF_INET6 if ":" in host else socket.AF_INET
|
||
with socket.socket(family=family, type=socket.SOCK_STREAM) as probe:
|
||
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||
try:
|
||
probe.bind((host, port))
|
||
except OSError as exc:
|
||
reason = exc.strerror or str(exc)
|
||
raise click.ClickException(
|
||
f"Cannot start server on {host}:{port}: port is unavailable ({reason})."
|
||
) from exc
|
||
|
||
|
||
@cli.group("server", invoke_without_command=True)
|
||
@click.option(
|
||
"--host",
|
||
default="127.0.0.1",
|
||
show_default=True,
|
||
help="Host to bind to.",
|
||
)
|
||
@click.option(
|
||
"--port",
|
||
"-p",
|
||
default=_DEFAULT_LOCAL_PORT,
|
||
show_default=True,
|
||
type=int,
|
||
help="Port to listen on.",
|
||
)
|
||
@click.option(
|
||
"--database-uri",
|
||
default=None,
|
||
help="Database URI for stores. [default: sqlite at <data-dir>/chat.db, "
|
||
"machine-global so `server` and `run` share one admin]",
|
||
)
|
||
@click.option(
|
||
"--artifact-location",
|
||
default=None,
|
||
help="Path for artifact storage. [default: <data-dir>/artifacts]",
|
||
)
|
||
@click.option(
|
||
"--config",
|
||
"-c",
|
||
"config_path",
|
||
type=click.Path(exists=True),
|
||
default=None,
|
||
help="Path to YAML config file.",
|
||
)
|
||
@click.option(
|
||
"--execution-timeout",
|
||
default=None,
|
||
type=int,
|
||
help="Max wall-clock seconds per agent execution. [default: 7200]",
|
||
)
|
||
@click.option(
|
||
"--agent",
|
||
"agent_dirs",
|
||
multiple=True,
|
||
type=click.Path(exists=True),
|
||
help=(
|
||
"Pre-register an agent from a directory at startup. "
|
||
"Can be repeated. If the agent name already exists, "
|
||
"the bundle is replaced."
|
||
),
|
||
)
|
||
@click.option(
|
||
"--open/--no-open",
|
||
"auto_open",
|
||
default=True,
|
||
help=(
|
||
"On first boot of accounts auth, open the magic-redeem URL in the "
|
||
"user's browser so the web UI signs in without password entry. "
|
||
"Default: --open. Pass --no-open for headless / SSH / Docker."
|
||
),
|
||
)
|
||
@click.option(
|
||
"--admin-password",
|
||
default=None,
|
||
help=(
|
||
"Set the first-run accounts admin password non-interactively "
|
||
"(alternative to OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD). Only "
|
||
"takes effect on the very first boot of a machine's accounts DB; "
|
||
"ignored with a warning if an admin already exists."
|
||
),
|
||
)
|
||
@click.pass_context
|
||
def server(
|
||
ctx: click.Context,
|
||
host: str,
|
||
port: int,
|
||
database_uri: str | None,
|
||
artifact_location: str | None,
|
||
config_path: str | None,
|
||
execution_timeout: int | None,
|
||
agent_dirs: tuple[str, ...],
|
||
auto_open: bool,
|
||
admin_password: str | None,
|
||
) -> None:
|
||
"""Start the Omnigent server in the foreground, or manage the background server.
|
||
|
||
Bare ``omnigent server`` runs the server in the FOREGROUND (Ctrl-C to
|
||
stop) — for deploys / Docker. Subcommands manage the detached background
|
||
server that ``run`` / ``claude`` / ``codex`` use: ``start`` (ensure it's
|
||
up), ``stop`` (stop it and the local host daemon), ``status`` (is it up?).
|
||
|
||
:param host: Interface to bind, e.g. ``"127.0.0.1"``.
|
||
:param ctx: Click invocation context used to tell whether
|
||
``--port`` came from the command line or from the default.
|
||
:param port: TCP port to listen on, e.g. ``6767``.
|
||
:param database_uri: Optional database URI, e.g.
|
||
``"sqlite:///omnigent.db"``.
|
||
:param artifact_location: Optional artifact location, e.g.
|
||
``"./artifacts"``.
|
||
:param config_path: Optional YAML config file path.
|
||
:param execution_timeout: Optional max agent execution seconds,
|
||
e.g. ``7200``.
|
||
:param agent_dirs: Agent directories or YAML files passed with
|
||
``--agent``.
|
||
:param auto_open: Whether to open the magic-redeem URL in the
|
||
user's browser on first boot of accounts mode. Translated
|
||
into the ``OMNIGENT_ACCOUNTS_AUTO_OPEN`` env var so the
|
||
lifespan startup hook (which actually fires the open after
|
||
uvicorn binds) reads it without a kwarg threading change.
|
||
:param admin_password: Optional first-run accounts admin password
|
||
from ``--admin-password``, e.g. ``"hunter2"``. Folded into the
|
||
``OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD`` env var that
|
||
bootstrap reads; ``None`` leaves the env var untouched.
|
||
:returns: None.
|
||
"""
|
||
if ctx.invoked_subcommand is not None:
|
||
# A subcommand (start/stop/status) handles this invocation; the body
|
||
# below is the foreground-server path for the bare ``server`` group.
|
||
return
|
||
port_source = ctx.get_parameter_source("port")
|
||
port_was_explicit = port_source is click.core.ParameterSource.COMMANDLINE
|
||
if port_was_explicit:
|
||
_assert_server_port_bindable(host, port)
|
||
|
||
# --admin-password is sugar for the INIT_ADMIN_PASSWORD env var that
|
||
# bootstrap_admin already consumes — fold it in here so the rest of
|
||
# the startup path has a single source. setdefault so an explicit
|
||
# env var wins over the flag (consistent with "explicit env wins").
|
||
# Whether it actually takes effect (vs. being ignored with a warning
|
||
# because an admin already exists) is decided in bootstrap_admin.
|
||
if admin_password:
|
||
os.environ.setdefault("OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD", admin_password)
|
||
|
||
# Translate --no-open into the env var the lifespan hook reads.
|
||
# We use an env var rather than threading the flag through
|
||
# create_app so the same toggle works for callers (Docker
|
||
# entrypoint, future `omnigent run`) that build the app
|
||
# outside this CLI command.
|
||
os.environ["OMNIGENT_ACCOUNTS_AUTO_OPEN"] = "1" if auto_open else "0"
|
||
|
||
# Unified local-server lifecycle — applies ONLY to a *bare* loopback
|
||
# `omnigent server` (default port + default DB + artifacts), i.e.
|
||
# THE canonical machine-global local server recorded in
|
||
# ~/.omnigent/local_server.pid:
|
||
# - If a healthy one is already running (started here OR spawned by
|
||
# the `run`/`host` daemon), reuse it — print its URL and exit
|
||
# instead of starting a competing second server on the shared DB.
|
||
# - Otherwise prefer the requested port (default 6767), falling back
|
||
# to a free one if taken, and register ourselves in the pidfile so
|
||
# the daemon reuses THIS server. (See host/local_server.py.)
|
||
#
|
||
# An explicit --port / --database-uri / --artifact-location means "be a
|
||
# DEDICATED server here" — the daemon's own spawn (ensure_local_omnigent_server)
|
||
# and the e2e harness both do this. Such a server must bind its requested
|
||
# port and must NOT consult or register in the shared pidfile, or it would
|
||
# reuse/hijack the canonical server and exit without ever binding its port.
|
||
# Likewise a non-loopback bind (`--host 0.0.0.0`, a real deploy) is exempt
|
||
# and binds the exact port.
|
||
_is_canonical_local_server = (
|
||
host in ("127.0.0.1", "localhost")
|
||
and database_uri is None
|
||
and artifact_location is None
|
||
and not port_was_explicit
|
||
)
|
||
|
||
# Single-user marker: ANY loopback-bound `omnigent server` running
|
||
# the env-unset header default IS a local single-user runtime — the
|
||
# user's own machine, no proxy to inject identity — so it keeps the
|
||
# no-login header-mode "local" fallback (same posture as the daemon
|
||
# / `omnigent run` spawn paths, which set this var themselves). The
|
||
# bind address is the discriminator, NOT the port/db-uri: a
|
||
# dedicated `omnigent server --port 9001 --database-uri …` on
|
||
# loopback (manual local runs, the e2e harness) is still single
|
||
# user, so it must not 401 its own headerless traffic. What stays
|
||
# fail-closed: a non-loopback bind (`--host 0.0.0.0`,
|
||
# a network-exposed deploy — those MUST front a proxy or use
|
||
# accounts/oidc) and an explicit OMNIGENT_AUTH_PROVIDER=header
|
||
# deploy behind an identity-injecting proxy. setdefault so an
|
||
# operator's explicit OMNIGENT_LOCAL_SINGLE_USER=0 wins. Must run
|
||
# before create_auth_provider() below, which reads the var.
|
||
from omnigent.server.auth import resolve_auth_source as _resolve_auth_source
|
||
|
||
_is_loopback_bind = host in ("127.0.0.1", "localhost", "::1")
|
||
# Compose-style deploys pass OMNIGENT_AUTH_PROVIDER as an empty
|
||
# string when unset ("${VAR:-}"), so empty and missing both mean
|
||
# "not explicitly pinned".
|
||
_raw_auth_provider = os.environ.get("OMNIGENT_AUTH_PROVIDER")
|
||
_auth_provider_explicit = bool(_raw_auth_provider and _raw_auth_provider.strip())
|
||
if _is_loopback_bind and not _auth_provider_explicit and _resolve_auth_source() == "header":
|
||
os.environ.setdefault("OMNIGENT_LOCAL_SINGLE_USER", "1")
|
||
|
||
if _is_canonical_local_server:
|
||
from omnigent.host.local_server import (
|
||
local_server_url_if_healthy,
|
||
pick_local_port,
|
||
)
|
||
|
||
_existing = local_server_url_if_healthy()
|
||
if _existing is not None:
|
||
click.echo(
|
||
f"A local server is already running at {_existing} — reusing it.\n"
|
||
"Stop it first if you want to start a fresh one "
|
||
"(or pass --server <url> to target a different server)."
|
||
)
|
||
return
|
||
_picked = pick_local_port(port)
|
||
if _picked != port:
|
||
click.echo(
|
||
f" ⚠ port {port} is busy — using {_picked} instead.",
|
||
err=True,
|
||
)
|
||
port = _picked
|
||
|
||
import uvicorn
|
||
import uvicorn.server
|
||
|
||
from omnigent.runner.transports.ws_tunnel.limits import (
|
||
RUNNER_TUNNEL_MAX_MESSAGE_BYTES,
|
||
TUNNEL_KEEPALIVE_PING_INTERVAL_S,
|
||
TUNNEL_KEEPALIVE_PING_TIMEOUT_S,
|
||
)
|
||
from omnigent.server.app import create_app
|
||
from omnigent.server.auth import create_auth_provider
|
||
from omnigent.server.server_config import config_str_list
|
||
from omnigent.stores.agent_store.sqlalchemy_store import SqlAlchemyAgentStore
|
||
from omnigent.stores.comment_store.sqlalchemy_store import SqlAlchemyCommentStore
|
||
from omnigent.stores.conversation_store.sqlalchemy_store import (
|
||
SqlAlchemyConversationStore,
|
||
)
|
||
from omnigent.stores.file_store.sqlalchemy_store import SqlAlchemyFileStore
|
||
from omnigent.stores.policy_store.sqlalchemy_store import SqlAlchemyPolicyStore
|
||
|
||
cfg = _load_config(config_path)
|
||
|
||
# CLI args take precedence over config file, which takes precedence
|
||
# over defaults.
|
||
db_uri = database_uri or cfg.get("database_uri", _default_db_uri())
|
||
art_loc = artifact_location or cfg.get("artifact_location", _default_artifact_location())
|
||
|
||
# Resolve relative artifact location against config file's directory
|
||
# (only when the value came from the config file, not CLI).
|
||
if config_path and artifact_location is None and not Path(art_loc).is_absolute():
|
||
art_loc = str(Path(config_path).parent / art_loc)
|
||
|
||
# SQLite won't create the DB file's parent dir; do it before any store
|
||
# connects, else a fresh <data_dir> (first run, or a cleared dir) fails
|
||
# with "unable to open database file".
|
||
_ensure_sqlite_parent_dir(db_uri)
|
||
|
||
from omnigent.stores.permission_store.sqlalchemy_store import SqlAlchemyPermissionStore
|
||
|
||
agent_store = SqlAlchemyAgentStore(db_uri)
|
||
file_store = SqlAlchemyFileStore(db_uri)
|
||
conversation_store = SqlAlchemyConversationStore(db_uri)
|
||
comment_store = SqlAlchemyCommentStore(db_uri)
|
||
policy_store = SqlAlchemyPolicyStore(db_uri)
|
||
permission_store = SqlAlchemyPermissionStore(db_uri)
|
||
artifact_store = _create_artifact_store(art_loc)
|
||
|
||
# Initialize the runtime with store references so workflow code
|
||
# can access them via getter functions (get_agent_cache(), etc.).
|
||
from omnigent.runtime import init as init_runtime
|
||
from omnigent.runtime.agent_cache import AgentCache
|
||
from omnigent.runtime.caps import RuntimeCaps
|
||
|
||
agent_cache = AgentCache(
|
||
artifact_store=artifact_store,
|
||
cache_dir=Path(art_loc) / ".cache",
|
||
)
|
||
# CLI flag > config file > RuntimeCaps default (7200s = 2 hours).
|
||
# 7200 matches RuntimeCaps.execution_timeout default.
|
||
effective_timeout = execution_timeout or cfg.get("execution_timeout") or 7200
|
||
|
||
from omnigent.spec import parse_default_policies, parse_server_llm
|
||
|
||
server_llm = parse_server_llm(cfg.get("llm"))
|
||
|
||
# Build the default LLM-based routing client when BOTH the server
|
||
# has an ``llm:`` config AND the feature is explicitly enabled via
|
||
# OMNIGENT_SMART_ROUTING=1. Hidden by default — managed deployments
|
||
# override RuntimeCaps.routing_client with their own implementation.
|
||
routing_client = None
|
||
if server_llm is not None and os.environ.get("OMNIGENT_SMART_ROUTING") == "1":
|
||
from omnigent.runtime.policies.builder import (
|
||
_build_policy_llm_client,
|
||
_resolve_server_llm_connection,
|
||
)
|
||
|
||
_conn = _resolve_server_llm_connection(server_llm)
|
||
_policy_client = _build_policy_llm_client(server_llm, _conn)
|
||
if _policy_client is not None:
|
||
from omnigent.server.smart_routing import LLMRoutingClient
|
||
|
||
routing_client = LLMRoutingClient(_policy_client)
|
||
|
||
caps = RuntimeCaps(
|
||
execution_timeout=int(effective_timeout),
|
||
default_policies=parse_default_policies(cfg.get("policies")),
|
||
llm=server_llm,
|
||
routing_client=routing_client,
|
||
)
|
||
init_runtime(
|
||
conversation_store=conversation_store,
|
||
agent_store=agent_store,
|
||
agent_cache=agent_cache,
|
||
file_store=file_store,
|
||
artifact_store=artifact_store,
|
||
comment_store=comment_store,
|
||
policy_store=policy_store,
|
||
caps=caps,
|
||
)
|
||
|
||
# Initialize OpenTelemetry observability. No-op when
|
||
# OTEL_EXPORTER_OTLP_ENDPOINT is unset; see
|
||
# designs/OBSERVABILITY.md for the env var reference.
|
||
from omnigent.runtime import telemetry
|
||
|
||
telemetry.init("omni-server")
|
||
|
||
# Read a pre-shared tunnel token from the environment if the
|
||
# caller (e.g. _start_local_server) spawns the runner externally
|
||
# and needs the server to accept exactly that runner's tunnel.
|
||
# When unset the server accepts any token-bound runner
|
||
# (runner_tunnel_tokens=None) — the standard posture for deployed
|
||
# servers where runners authenticate via Databricks OAuth.
|
||
_tunnel_token = os.environ.get("OMNIGENT_RUNNER_TUNNEL_TOKEN")
|
||
_runner_tunnel_tokens: frozenset[str] | None = (
|
||
frozenset({_tunnel_token}) if _tunnel_token else None
|
||
)
|
||
|
||
# Pre-register agents from --agent directories.
|
||
for agent_dir in agent_dirs:
|
||
_preregister_agent(
|
||
Path(agent_dir),
|
||
agent_store,
|
||
artifact_store,
|
||
agent_cache,
|
||
)
|
||
|
||
from omnigent.stores.host_store import HostStore
|
||
|
||
host_store = HostStore(db_uri)
|
||
|
||
# Managed sandbox hosts (host_type="managed" sessions): parse the
|
||
# config's `sandbox:` section up front so an operator typo stops
|
||
# startup instead of 502-ing the first managed session.
|
||
from omnigent.server.managed_hosts import parse_sandbox_config
|
||
|
||
try:
|
||
sandbox_config = parse_sandbox_config(cfg.get("sandbox"))
|
||
except ValueError as exc:
|
||
raise click.ClickException(str(exc)) from exc
|
||
|
||
# Accounts mode ergonomics: when accounts mode is selected
|
||
# (OMNIGENT_AUTH_ENABLED=1 without OIDC config, or an explicit
|
||
# OMNIGENT_AUTH_PROVIDER=accounts), supply sensible defaults
|
||
# for the two vars they would otherwise have to set manually.
|
||
# Both defaults respect operator overrides (setdefault, no
|
||
# override clobber). We gate on the *resolved* selection (not
|
||
# just "auth provider unset") so a bare header-mode local server
|
||
# — the env-unset default — and an OIDC deploy don't mint accounts
|
||
# secrets they never read.
|
||
#
|
||
# COOKIE_SECRET: persist in the artifact dir so sessions survive
|
||
# restart. Operator-set value still wins for HA deploys.
|
||
# BASE_URL: default to the CLI's bind+port so local dev "just
|
||
# works". Docker / remote deploys behind a public domain still
|
||
# set this explicitly.
|
||
from omnigent.server.auth import resolve_auth_source
|
||
|
||
if resolve_auth_source() == "accounts":
|
||
from omnigent.server.accounts_secret import load_or_generate_cookie_secret
|
||
|
||
os.environ.setdefault(
|
||
"OMNIGENT_ACCOUNTS_COOKIE_SECRET",
|
||
load_or_generate_cookie_secret(art_loc),
|
||
)
|
||
os.environ.setdefault("OMNIGENT_ACCOUNTS_BASE_URL", f"http://{host}:{port}")
|
||
|
||
auth_provider = create_auth_provider()
|
||
|
||
# Accounts mode: construct the AccountStore (sibling to PermissionStore)
|
||
# here and pass it to create_app explicitly. Any deploy that doesn't run
|
||
# accounts (the internal hosted product) passes account_store=None and
|
||
# the entire accounts surface stays inactive.
|
||
account_store = None
|
||
from omnigent.server.auth import UnifiedAuthProvider as _UAP
|
||
|
||
if isinstance(auth_provider, _UAP) and auth_provider._source == "accounts":
|
||
from omnigent.server.accounts_store import SqlAlchemyAccountStore
|
||
|
||
account_store = SqlAlchemyAccountStore(db_uri)
|
||
|
||
app = create_app(
|
||
agent_store=agent_store,
|
||
file_store=file_store,
|
||
conversation_store=conversation_store,
|
||
comment_store=comment_store,
|
||
policy_store=policy_store,
|
||
artifact_store=artifact_store,
|
||
agent_cache=agent_cache,
|
||
runner_tunnel_tokens=_runner_tunnel_tokens,
|
||
permission_store=permission_store,
|
||
auth_provider=auth_provider,
|
||
host_store=host_store,
|
||
account_store=account_store,
|
||
policy_modules=cfg.get("policy_modules"),
|
||
admins=config_str_list(cfg.get("admins")),
|
||
allowed_domains=config_str_list(cfg.get("allowed_domains")),
|
||
sandbox_config=sandbox_config,
|
||
)
|
||
|
||
click.echo(f"Starting omnigent server on {host}:{port}")
|
||
click.echo(f" database: {db_uri}")
|
||
click.echo(f" artifacts: {art_loc}")
|
||
# A foreground server streams uvicorn logs to this terminal, but the
|
||
# always-on diagnostics (omnigent.* loggers, captured warnings) also land
|
||
# in a persistent per-invocation file — point at it so there's a concrete
|
||
# log to grep after the terminal scrolls. None only in the detached spawn
|
||
# path (`-m omnigent.cli server`, no setup_cli_logging), whose captured
|
||
# log `server start` already reports.
|
||
from omnigent.cli_diagnostics import current_cli_log_path
|
||
|
||
_cli_log = current_cli_log_path()
|
||
if _cli_log is not None:
|
||
click.echo(f" log: {_display_path(_cli_log)}")
|
||
|
||
# First-run terminal setup: the FALLBACK entry point. Fires only on
|
||
# an interactive TTY when no admin exists AND the browser isn't about
|
||
# to open the web Create-admin form (i.e. --no-open, or a non-loopback
|
||
# base URL). The default `omnigent server` on loopback opens the
|
||
# browser to the form instead, so this no-ops there. (The other entry
|
||
# points are --admin-password and the web form.)
|
||
_maybe_prompt_first_admin(account_store, auth_provider, auto_open=auto_open)
|
||
|
||
# Warn loudly when the SPA bundle is absent: the server still boots
|
||
# but serves an API-only JSON landing at "/", so the operator hits
|
||
# http://host:port expecting the web UI and gets JSON with no clue
|
||
# why. The bundle is npm-build output (not tracked in git); a dev
|
||
# checkout that never ran `npm run build` has an empty static dir.
|
||
from omnigent.server.app import _WEB_UI_DIST
|
||
|
||
if not (_WEB_UI_DIST / "index.html").is_file():
|
||
click.echo(
|
||
" ⚠ web UI not built — serving API only. "
|
||
"Run `cd web && npm install && npm run build`, "
|
||
"then restart (or install a release wheel/image).",
|
||
err=True,
|
||
)
|
||
|
||
# Advertise this server in the shared pidfile so the run/host
|
||
# daemon discovers and reuses it (loopback only). Cleared on exit so
|
||
# a clean shutdown doesn't leave a stale record.
|
||
if _is_canonical_local_server:
|
||
from omnigent.host.local_server import (
|
||
clear_local_server_record,
|
||
register_local_server,
|
||
)
|
||
|
||
# Stamp the same config signature host/run compute so they reuse
|
||
# this foreground server instead of tearing it down on a spurious
|
||
# sig mismatch.
|
||
register_local_server(port)
|
||
|
||
class _ShutdownSignalingServer(uvicorn.server.Server):
|
||
"""uvicorn.Server that signals active SSE subscribers before the
|
||
graceful-shutdown wait starts.
|
||
|
||
uvicorn calls ``Server.shutdown()`` in this order:
|
||
1. close listening sockets / call connection.shutdown()
|
||
2. ``asyncio.wait_for(_wait_tasks_to_complete(), timeout=…)``
|
||
3. force-cancel remaining tasks on timeout
|
||
4. run the ASGI lifespan shutdown handler
|
||
|
||
The ASGI lifespan ``finally`` block runs at step 4 — too late. SSE
|
||
generators waiting on a heartbeat tick are already force-cancelled by
|
||
step 3, which produces spurious ``CancelledError`` tracebacks.
|
||
Overriding here lets us drain SSE streams before step 2 so they exit
|
||
cleanly within the graceful window.
|
||
"""
|
||
|
||
async def shutdown(self, sockets=None) -> None: # type: ignore[override]
|
||
import asyncio as _asyncio
|
||
|
||
from omnigent.runtime import session_stream as _session_stream
|
||
|
||
_session_stream.shutdown_all()
|
||
# Yield to the event loop so generators can consume _DONE,
|
||
# flush their final "data: [DONE]\n\n" chunk, and exit before
|
||
# super().shutdown() calls connection.shutdown() / transport.close().
|
||
# Without this pause the generators write to an already-closing
|
||
# transport, leaving connections open past the graceful window.
|
||
await _asyncio.sleep(0)
|
||
await super().shutdown(sockets)
|
||
|
||
_config = uvicorn.Config(
|
||
app,
|
||
host=host,
|
||
port=port,
|
||
log_config=_server_uvicorn_log_config(),
|
||
ws_max_size=RUNNER_TUNNEL_MAX_MESSAGE_BYTES,
|
||
# Server side of the runner/host tunnels' protocol keepalive, aligned
|
||
# to the 90 s app-level budget instead of uvicorn's 20 s default that
|
||
# drops a busy-but-healthy tunnel with 1011 — issue #1116.
|
||
#
|
||
# uvicorn's ws_ping_* is server-global (no per-route override), so this
|
||
# 30 s/90 s budget also applies to the app's other WebSocket routes —
|
||
# /v1/sessions/updates (browser stream) and .../terminals/{id}/attach.
|
||
# Deliberate and acceptable: for an IDLE such socket the protocol
|
||
# PING/PONG is the only half-open detector (the sessions-updates
|
||
# heartbeat is a server->client send, and an idle terminal has no
|
||
# traffic), so widening it means a dead idle browser/terminal socket is
|
||
# reaped at worst ~120 s (30 s interval + 90 s timeout) instead of
|
||
# ~40 s — a slightly later half-open cleanup (e.g. the out-of-process
|
||
# terminal-attach proxy holds its runner socket + tmux child ~80 s
|
||
# longer), bounded and eventually reaped, not a leak or correctness
|
||
# change. The tunnels are the sockets that actually need the looser
|
||
# budget (issue #1116).
|
||
ws_ping_interval=TUNNEL_KEEPALIVE_PING_INTERVAL_S,
|
||
ws_ping_timeout=TUNNEL_KEEPALIVE_PING_TIMEOUT_S,
|
||
timeout_graceful_shutdown=_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S,
|
||
)
|
||
try:
|
||
_ShutdownSignalingServer(_config).run()
|
||
except KeyboardInterrupt:
|
||
# uvicorn.run() swallows KeyboardInterrupt; match that behaviour so
|
||
# a Ctrl-C exit doesn't print Click's "Aborted!" or exit non-zero.
|
||
pass
|
||
finally:
|
||
if _is_canonical_local_server:
|
||
clear_local_server_record()
|
||
|
||
|
||
def _stop_local_server_and_daemon(*, force: bool) -> bool:
|
||
"""Stop the background Omnigent server and the local host daemon that owns it.
|
||
|
||
Stops the local-mode host daemon first (the daemon spawns its server
|
||
once and never respawns it, so leaving it alive would only have it
|
||
reconnect-flap against a dead server), then the detached Omnigent server
|
||
recorded in ``~/.omnigent/local_server.pid``. Best-effort and
|
||
idempotent — a missing daemon or server is a no-op.
|
||
|
||
:param force: SIGKILL the daemon after the grace period if it does not
|
||
exit on SIGTERM.
|
||
:returns: ``True`` if a healthy background server was running when
|
||
called, ``False`` otherwise.
|
||
"""
|
||
was_running = local_server_url_if_healthy() is not None
|
||
local_record = _find_daemon_record(_LOCAL_DAEMON_MARKER)
|
||
if local_record is not None:
|
||
# A stubborn daemon shouldn't block stopping the server.
|
||
with contextlib.suppress(click.ClickException):
|
||
_terminate_daemon(local_record, force=force)
|
||
stop_local_omnigent_server()
|
||
# Also catch an orphan on the canonical port whose pidfile was lost, so
|
||
# `server stop` isn't blind to it (it reported "No background server is
|
||
# running" while one was still listening on the default port).
|
||
orphan_pid = stop_untracked_local_server()
|
||
return was_running or orphan_pid is not None
|
||
|
||
|
||
@server.command("start")
|
||
def server_start() -> None:
|
||
"""Ensure the managed background Omnigent server is running.
|
||
|
||
Reuses a healthy background server if one is already up (started here or
|
||
by a prior ``run`` / ``host``); otherwise spawns a detached one on a
|
||
free loopback port and prints its URL. The background counterpart to the
|
||
foreground bare ``omnigent server``.
|
||
|
||
:returns: None.
|
||
"""
|
||
startup = ensure_local_omnigent_server()
|
||
verb = (
|
||
"Started background server at"
|
||
if startup.spawned
|
||
else "Background server already running at"
|
||
)
|
||
click.echo(f"{verb} {startup.url}")
|
||
# Surface the exact log file so a detached server isn't a black box —
|
||
# `server start` is otherwise the only signal it ever emits. Known for a
|
||
# spawned server and (via the log-path sidecar) for a reused one too;
|
||
# absent only for a foreground `omnigent server` whose logs stream to
|
||
# its own terminal.
|
||
if startup.log_path is not None:
|
||
click.echo(f" log: {_display_path(startup.log_path)}")
|
||
|
||
|
||
@server.command("stop")
|
||
@click.option(
|
||
"--force",
|
||
is_flag=True,
|
||
help="SIGKILL the local host daemon if it does not exit on SIGTERM.",
|
||
)
|
||
def server_stop(force: bool) -> None:
|
||
"""Stop the background Omnigent server and the local host daemon.
|
||
|
||
Stops the local host daemon first, then the detached server recorded
|
||
in ``~/.omnigent/local_server.pid`` — its web UI and sessions become
|
||
unreachable. To stop hosting but KEEP the server up, use
|
||
``omnigent host stop``; to stop everything, use ``omnigent stop``.
|
||
|
||
:param force: SIGKILL the local host daemon after the grace period if it
|
||
does not exit on SIGTERM.
|
||
:returns: None.
|
||
"""
|
||
if _stop_local_server_and_daemon(force=force):
|
||
click.echo("Stopped the background server.")
|
||
else:
|
||
click.echo("No background server is running.")
|
||
|
||
|
||
@server.command("status")
|
||
@click.option("--json", "json_output", is_flag=True, help="Emit JSON.")
|
||
def server_status(json_output: bool) -> None:
|
||
"""Show whether the background Omnigent server is running.
|
||
|
||
Reports the recorded pid/port, URL, live-session count, and whether a
|
||
local host daemon is attached. Reads ``~/.omnigent/local_server.pid``
|
||
and probes ``/health``.
|
||
|
||
:param json_output: Emit machine-readable JSON instead of text.
|
||
:returns: None.
|
||
"""
|
||
info = local_server_status()
|
||
daemon_attached = _find_daemon_record(_LOCAL_DAEMON_MARKER) is not None
|
||
sessions: int | None = None
|
||
if info.running and info.url is not None:
|
||
# Session count crosses the HTTP boundary; a transient failure
|
||
# shouldn't break `status`, so leave the count unknown instead.
|
||
with contextlib.suppress(click.ClickException):
|
||
pages = _fetch_session_pages(base_url=info.url, connected_only=True)
|
||
sessions = len(pages.sessions)
|
||
if json_output:
|
||
click.echo(
|
||
json.dumps(
|
||
{
|
||
"running": info.running,
|
||
"pid": info.pid,
|
||
"port": info.port,
|
||
"url": info.url,
|
||
"log_path": str(info.log_path) if info.log_path else None,
|
||
"live_sessions": sessions,
|
||
"daemon_attached": daemon_attached,
|
||
},
|
||
indent=2,
|
||
)
|
||
)
|
||
return
|
||
if not info.running:
|
||
click.echo("Background server: not running.")
|
||
return
|
||
click.echo(f"Background server: running at {info.url} (pid {info.pid}, port {info.port})")
|
||
if info.log_path is not None:
|
||
click.echo(f" log: {_display_path(info.log_path)}")
|
||
if sessions is not None:
|
||
click.echo(f" live sessions: {sessions}")
|
||
click.echo(f" host daemon attached: {'yes' if daemon_attached else 'no'}")
|
||
|
||
|
||
@cli.command("stop")
|
||
@click.option(
|
||
"--force",
|
||
is_flag=True,
|
||
help="Continue past failures and SIGKILL daemons that do not exit on SIGTERM.",
|
||
)
|
||
def stop(force: bool) -> None:
|
||
"""Stop everything Omnigent is running on this machine.
|
||
|
||
The off switch: stops every host daemon (local and remote-targeted)
|
||
and the detached background server. Runners are reaped when their daemon
|
||
exits. To stop only hosting while keeping the local server (web UI /
|
||
history) up, use ``omnigent host stop`` instead.
|
||
|
||
:param force: Continue past individual failures and SIGKILL daemons that
|
||
do not exit on SIGTERM.
|
||
:returns: None.
|
||
"""
|
||
stopped = 0
|
||
failures: list[str] = []
|
||
for record in _list_daemon_records():
|
||
# Terminating the daemon reaps its runners (orphan-watchdog), so the
|
||
# off-switch doesn't need the graceful per-session HTTP stop that
|
||
# `host stop` does — that keeps teardown quiet and dependency-free.
|
||
try:
|
||
_terminate_daemon(record, force=force)
|
||
stopped += 1
|
||
except click.ClickException as exc:
|
||
failures.append(exc.message)
|
||
server_was_running = local_server_url_if_healthy() is not None
|
||
stop_local_omnigent_server()
|
||
# Sweep the canonical port for an orphaned server the pidfile lost track
|
||
# of (a torn/cleared record, or a respawn that landed elsewhere). Without
|
||
# this, that server survives the off-switch — the exact "I ran stop and a
|
||
# server is still on the default port" symptom.
|
||
orphan_pid = stop_untracked_local_server()
|
||
|
||
parts: list[str] = []
|
||
if stopped:
|
||
parts.append(f"{stopped} daemon(s)")
|
||
if server_was_running:
|
||
parts.append("the background server")
|
||
if orphan_pid is not None:
|
||
parts.append(f"an untracked server on :{_DEFAULT_LOCAL_PORT} (pid {orphan_pid})")
|
||
if parts:
|
||
click.echo("Stopped " + " and ".join(parts) + ".")
|
||
else:
|
||
click.echo("Nothing to stop.")
|
||
if failures:
|
||
raise click.ClickException("; ".join(failures) + " — retry with --force.")
|
||
|
||
|
||
def _count_running_sessions(base_url: str) -> int:
|
||
"""Count sessions actively running a turn on the local server.
|
||
|
||
Gates on the session-list ``status`` field (``"running"`` — a runner
|
||
mid-turn, or with a still-running sub-agent), NOT mere connectedness:
|
||
an idle session keeps its host/runner connection open indefinitely, so
|
||
counting connected sessions would make the drain wait forever for
|
||
sessions that aren't doing any work. Only ``"running"`` sessions hold
|
||
in-flight work an upgrade should avoid interrupting.
|
||
|
||
A transient HTTP failure is treated as "none running" rather than
|
||
blocking the upgrade — the server's own graceful shutdown still drains
|
||
any runner that happens to be mid-turn.
|
||
|
||
:param base_url: Local server base URL, e.g. ``"http://127.0.0.1:6767"``.
|
||
:returns: Number of sessions with ``status == "running"``, or ``0`` on
|
||
a query failure.
|
||
"""
|
||
with contextlib.suppress(click.ClickException):
|
||
pages = _fetch_session_pages(base_url=base_url, connected_only=True)
|
||
return sum(1 for session in pages.sessions if session.get("status") == "running")
|
||
return 0
|
||
|
||
|
||
def _wait_for_local_sessions_to_drain() -> None:
|
||
"""Block until no local session is actively running a turn.
|
||
|
||
Used by ``omni upgrade`` (without ``--force``) so an upgrade never
|
||
yanks a running agent turn. Waits only on sessions whose status is
|
||
``"running"`` (see :func:`_count_running_sessions`) — idle-but-connected
|
||
sessions do not hold it up. Polls every :data:`_UPGRADE_DRAIN_POLL_S`
|
||
seconds and re-prints the count whenever it changes; ``Ctrl-C`` aborts
|
||
the wait (and the upgrade) cleanly. Returns immediately when the server
|
||
is down or already idle.
|
||
"""
|
||
info = local_server_status()
|
||
if not (info.running and info.url is not None):
|
||
return
|
||
count = _count_running_sessions(info.url)
|
||
if count == 0:
|
||
return
|
||
click.echo(
|
||
f"Waiting for {count} running session(s) to finish — press Ctrl-C to "
|
||
"abort, or re-run with --force to stop them now."
|
||
)
|
||
last = count
|
||
while True:
|
||
time.sleep(_UPGRADE_DRAIN_POLL_S)
|
||
info = local_server_status()
|
||
if not (info.running and info.url is not None):
|
||
return
|
||
count = _count_running_sessions(info.url)
|
||
if count == 0:
|
||
return
|
||
if count != last:
|
||
click.echo(f" {count} session(s) still running…")
|
||
last = count
|
||
|
||
|
||
def _drain_and_stop_local_server(*, force: bool) -> None:
|
||
"""Drain (or force-stop) the local server + daemon before an upgrade.
|
||
|
||
Shared by both ``omni upgrade`` paths (registry and git): the running
|
||
process must stop serving BEFORE its code is swapped, so it never serves
|
||
half-upgraded modules. The next ``omni`` invocation respawns a fresh
|
||
server on the new version.
|
||
|
||
:param force: When ``False``, wait for in-flight sessions to drain first;
|
||
when ``True``, stop them immediately.
|
||
"""
|
||
if not force:
|
||
_wait_for_local_sessions_to_drain()
|
||
if _stop_local_server_and_daemon(force=force):
|
||
click.echo("Stopped the background server before upgrading.")
|
||
|
||
|
||
def _upgrade_vcs_install(
|
||
info: _InstalledWheelInfo, *, check_only: bool, force: bool, pre: bool
|
||
) -> None:
|
||
"""Update a git/VCS ``omni`` install by re-pulling its tracked ref.
|
||
|
||
A git install's version string is frozen at whatever its source branch
|
||
declares (e.g. ``0.1.0`` on an unbumped ``main``), so it cannot be
|
||
compared against PyPI — that comparison reports a build *ahead* of the
|
||
latest release as "behind" and never converges, because reinstalling the
|
||
ref can't change the version string. Instead, compare the installed commit
|
||
against the remote ref's HEAD, and after re-pulling verify the commit
|
||
actually moved rather than asserting a PyPI version the ref can't produce.
|
||
|
||
:param info: Installed-distribution metadata, with ``info.vcs_url`` set.
|
||
:param check_only: Report status only; exit non-zero only when we can
|
||
positively confirm the install is behind its tracked ref.
|
||
:param force: Stop in-flight sessions immediately instead of draining.
|
||
:param pre: Pass the installer's allow-pre-releases flag (no-op for git).
|
||
"""
|
||
from omnigent.update_check import (
|
||
_build_upgrade_suggestion,
|
||
_probe_installed_distribution,
|
||
_remote_git_head,
|
||
_run_upgrade_command,
|
||
)
|
||
|
||
current_sha = info.commit_sha or ""
|
||
cur_short = current_sha[:9] if current_sha else "unknown"
|
||
remote_sha = _remote_git_head(info.vcs_url) if info.vcs_url else None
|
||
remote_short = remote_sha[:9] if remote_sha else ""
|
||
known_behind = bool(remote_sha and current_sha and remote_sha != current_sha)
|
||
|
||
if remote_sha and current_sha and remote_sha == current_sha:
|
||
click.echo(f"omnigent is up to date (git {cur_short}, tracking {info.vcs_url}).")
|
||
return
|
||
if known_behind:
|
||
click.echo(
|
||
f"A newer commit is available: {cur_short} → {remote_short} "
|
||
f"(git install tracking {info.vcs_url})."
|
||
)
|
||
else:
|
||
click.echo(
|
||
f"This is a git install ({info.vcs_url} @ {cur_short}). The latest "
|
||
"commit couldn't be determined; re-pulling the tracked ref."
|
||
)
|
||
|
||
if check_only:
|
||
# Exit non-zero only when we KNOW it's behind, so `--check` stays a
|
||
# reliable CI gate; an indeterminate remote is not a failure. SystemExit
|
||
# (not ctx.exit) for the same reason as the PyPI path — main() runs the
|
||
# group with standalone_mode=False, where ctx.exit's code is dropped.
|
||
if known_behind:
|
||
raise SystemExit(1)
|
||
return
|
||
|
||
if pre:
|
||
# ``--pre`` only steers a PyPI resolve; a git install gets exactly the
|
||
# commit its ref points at, so say so rather than implying it had effect.
|
||
click.echo(
|
||
"Note: --pre has no effect on a git install; the tracked ref decides the commit."
|
||
)
|
||
|
||
suggestion = _build_upgrade_suggestion(info, allow_prerelease=pre)
|
||
if not suggestion.runnable:
|
||
raise click.ClickException(
|
||
f"No automatic upgrade command is known for this install. {suggestion.command}."
|
||
)
|
||
|
||
_drain_and_stop_local_server(force=force)
|
||
|
||
console = Console()
|
||
code = _run_upgrade_command(suggestion.command, console)
|
||
if code != 0:
|
||
raise click.ClickException(
|
||
f"Upgrade command exited with status {code}; your previous install is intact."
|
||
)
|
||
|
||
# Verify by commit, not exit code: a re-pull of a ref that hasn't moved (or
|
||
# a pinned ref, or a cached reinstall) exits 0 without changing anything.
|
||
_, new_sha = _probe_installed_distribution()
|
||
if new_sha and current_sha and new_sha != current_sha:
|
||
click.echo(
|
||
f"✓ Updated to git {new_sha[:9]}. Re-run your command — the local "
|
||
"server will start on the new version."
|
||
)
|
||
return
|
||
if known_behind and new_sha and new_sha == current_sha:
|
||
# We positively confirmed the ref had advanced, yet the re-pull left the
|
||
# install on the same commit — a silent no-op that would otherwise
|
||
# recreate the "still behind" loop. Fail loudly, mirroring the PyPI guard.
|
||
raise click.ClickException(
|
||
f"The re-pull ran but the install is still at {cur_short} (the ref is at "
|
||
f"{remote_short}). The ref may be pinned or the reinstall reused a cached "
|
||
f"commit; try `uv tool install --reinstall {info.vcs_url}`."
|
||
)
|
||
if new_sha and current_sha and new_sha == current_sha:
|
||
# Remote was indeterminate, so we never claimed it was behind — a
|
||
# no-change re-pull is fine here.
|
||
click.echo(
|
||
f"Already on the latest commit of the tracked ref ({cur_short}); nothing changed."
|
||
)
|
||
return
|
||
# Couldn't read the new commit — the re-pull ran, but don't assert a
|
||
# result we can't confirm.
|
||
click.echo("Re-pulled the git ref. Run `omni upgrade --check` to confirm.")
|
||
|
||
|
||
@cli.command("upgrade")
|
||
@click.option(
|
||
"--check",
|
||
"check_only",
|
||
is_flag=True,
|
||
help="Report whether a newer release is available, without upgrading. "
|
||
"Exits non-zero when a newer release exists.",
|
||
)
|
||
@click.option(
|
||
"--force",
|
||
is_flag=True,
|
||
help="Stop in-flight sessions immediately instead of waiting for them to drain.",
|
||
)
|
||
@click.option(
|
||
"--pre",
|
||
"pre",
|
||
is_flag=True,
|
||
help="Consider pre-releases (e.g. release candidates), and pass the "
|
||
"installer's allow-pre-releases flag. Useful for validating a TestPyPI rc.",
|
||
)
|
||
def upgrade(check_only: bool, force: bool, pre: bool) -> None:
|
||
"""Upgrade the omnigent CLI to the latest release on PyPI.
|
||
|
||
Detects how omnigent was installed (uv / pip / pipx / poetry), checks
|
||
the configured index for a newer release and — unless ``--check`` —
|
||
drains and stops the local background server and host daemon, then runs
|
||
the matching upgrade command. The next ``omni`` invocation starts a
|
||
fresh server on the new code automatically (via the version-aware
|
||
config signature), so no explicit restart is needed.
|
||
|
||
In-flight agent sessions are waited on by default; pass ``--force`` to
|
||
stop them immediately. Pass ``--pre`` to consider pre-releases (rc /
|
||
beta) — handy for validating a TestPyPI candidate against your
|
||
configured index. Source checkouts / editable installs are not upgraded
|
||
here — update those with ``git pull``.
|
||
|
||
:param check_only: Only report availability; do not upgrade. Exits
|
||
with status 1 when a newer release exists.
|
||
:param force: Stop in-flight sessions immediately rather than draining.
|
||
:param pre: Consider pre-releases and allow the installer to fetch them.
|
||
:returns: None.
|
||
"""
|
||
import importlib.metadata
|
||
|
||
from omnigent.update_check import (
|
||
_UPGRADE_INDEX_TIMEOUT_SECONDS,
|
||
_build_upgrade_suggestion,
|
||
_find_repo_root,
|
||
_is_newer,
|
||
_probe_installed_distribution,
|
||
_read_installed_wheel_info,
|
||
_run_upgrade_command,
|
||
fetch_latest_version,
|
||
)
|
||
|
||
# Source checkout / editable install — there's no released wheel to
|
||
# swap in place; the correct update path is git, not a reinstall.
|
||
if _find_repo_root() is not None:
|
||
raise click.ClickException(
|
||
"This is a source checkout — update it with `git pull` (and reinstall "
|
||
"dependencies), not `omni upgrade`."
|
||
)
|
||
info = _read_installed_wheel_info()
|
||
if info is None:
|
||
raise click.ClickException(
|
||
"Couldn't determine how omnigent is installed; upgrade it manually."
|
||
)
|
||
if info.is_editable:
|
||
raise click.ClickException(
|
||
"This is an editable install — update it with `git pull`, not `omni upgrade`."
|
||
)
|
||
|
||
# A git/VCS install tracks a moving git ref, not a PyPI release. Its
|
||
# version string (a frozen ``0.1.0`` on an unbumped ``main``, say) is NOT
|
||
# comparable to the latest PyPI release: comparing them reports a build
|
||
# that is *ahead* of the release as "behind" and loops forever, because
|
||
# reinstalling the ref can never change that version string. For these
|
||
# installs "upgrade" means re-pulling the ref — compared and verified by
|
||
# commit, not by PyPI version.
|
||
if info.vcs_url:
|
||
_upgrade_vcs_install(info, check_only=check_only, force=force, pre=pre)
|
||
return
|
||
|
||
current = importlib.metadata.version("omnigent")
|
||
# User-initiated: a more forgiving timeout + one retry so a momentarily slow
|
||
# mirror doesn't spuriously report the index as unreachable.
|
||
latest = fetch_latest_version(
|
||
include_prereleases=pre, timeout=_UPGRADE_INDEX_TIMEOUT_SECONDS, attempts=2
|
||
)
|
||
if latest is None:
|
||
raise click.ClickException(
|
||
"Couldn't reach the package index to check for a newer release. Check your "
|
||
"connection (or OMNIGENT_INDEX_URL / your configured index) and try again."
|
||
)
|
||
if not _is_newer(latest, current):
|
||
click.echo(f"omnigent is up to date (v{current}).")
|
||
return
|
||
|
||
click.echo(f"A new release is available: v{current} → v{latest}.")
|
||
if check_only:
|
||
# Non-zero so scripts/CI can gate on "an upgrade is available".
|
||
# SystemExit (not ctx.exit) because main() runs the group with
|
||
# standalone_mode=False, where ctx.exit's code is returned and
|
||
# dropped rather than applied — SystemExit propagates correctly.
|
||
raise SystemExit(1)
|
||
|
||
suggestion = _build_upgrade_suggestion(info, allow_prerelease=pre)
|
||
if not suggestion.runnable:
|
||
raise click.ClickException(
|
||
f"No automatic upgrade command is known for this install. {suggestion.command}."
|
||
)
|
||
|
||
_drain_and_stop_local_server(force=force)
|
||
|
||
console = Console()
|
||
code = _run_upgrade_command(suggestion.command, console)
|
||
if code != 0:
|
||
raise click.ClickException(
|
||
f"Upgrade command exited with status {code}; your previous install is intact."
|
||
)
|
||
|
||
# Trust the installed version, not the installer's exit code. The running
|
||
# process still has the OLD version loaded, so re-read it in a fresh
|
||
# subprocess. A no-op upgrade (version-pinned spec, a cooldown /
|
||
# exclude-newer that excludes the new release, or a stale index cache)
|
||
# exits 0 without moving — claiming "✓ Upgraded" there is exactly the
|
||
# "I upgraded but it still says an update is available" bug.
|
||
new_version, _ = _probe_installed_distribution()
|
||
if new_version is None:
|
||
click.echo(
|
||
"Ran the upgrade command, but couldn't confirm the installed version. "
|
||
"Run `omni upgrade --check` to verify."
|
||
)
|
||
return
|
||
if _is_newer(new_version, current):
|
||
click.echo(
|
||
f"✓ Upgraded to v{new_version}. Re-run your command — the local "
|
||
"server will start on the new version."
|
||
)
|
||
return
|
||
raise click.ClickException(
|
||
f"The upgrade command ran but omnigent is still v{new_version} (expected "
|
||
f"v{latest}). The install is likely version-pinned, a cooldown / "
|
||
"exclude-newer is excluding the new release, or the index cache is stale. "
|
||
"Reinstall it explicitly — e.g. `uv tool upgrade --reinstall omnigent` or "
|
||
f"`pip install --force-reinstall 'omnigent=={latest}'`."
|
||
)
|
||
|
||
|
||
# ``omni update`` is an alias for ``omni upgrade`` — mistyping the latter as
|
||
# the former is common, and silently doing nothing is annoying. Registering
|
||
# the same Command object under a second name shares the exact callback,
|
||
# options, and semantics; there is no duplicated implementation to drift.
|
||
cli.add_command(upgrade, name="update")
|
||
|
||
|
||
def _bundle(source: Path) -> bytes:
|
||
"""
|
||
Produce a tar.gz bundle from a directory or standalone
|
||
Omnigent YAML file, or pass through an existing tarball.
|
||
|
||
Environment variable references (``${VAR}``) in
|
||
``config.yaml`` and ``tools/mcp/*.yaml`` are expanded
|
||
using the client's environment before bundling. This
|
||
ensures the server receives resolved secrets rather
|
||
than unresolved ``${VAR}`` references it cannot
|
||
resolve.
|
||
|
||
:param source: Path to an agent image directory,
|
||
standalone Omnigent YAML file, or an existing
|
||
``.tar.gz`` bundle file.
|
||
:returns: The gzipped tarball bytes.
|
||
:raises OmnigentError: If a required env var is
|
||
missing during expansion.
|
||
"""
|
||
import io
|
||
import tarfile
|
||
|
||
if source.is_file() and source.suffix.lower() in {".yaml", ".yml"}:
|
||
from omnigent.spec import materialize_bundle
|
||
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
bundle_dir = materialize_bundle(source, Path(tmpdir) / "bundle")
|
||
buf = io.BytesIO()
|
||
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
|
||
for file_path in bundle_dir.rglob("*"):
|
||
if file_path.is_file():
|
||
tf.add(str(file_path), arcname=str(file_path.relative_to(bundle_dir)))
|
||
return buf.getvalue()
|
||
|
||
if source.is_file():
|
||
return source.read_bytes()
|
||
|
||
# Pre-resolve env vars in YAML files that contain secrets.
|
||
resolved = _resolve_bundle_env_vars(source)
|
||
|
||
buf = io.BytesIO()
|
||
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
|
||
for file_path in source.rglob("*"):
|
||
if file_path.is_file():
|
||
arcname = str(file_path.relative_to(source))
|
||
if arcname in resolved:
|
||
# Write the resolved YAML instead of the
|
||
# original file (which has ${VAR} refs).
|
||
data = resolved[arcname].encode("utf-8")
|
||
info = tarfile.TarInfo(name=arcname)
|
||
info.size = len(data)
|
||
tf.addfile(info, io.BytesIO(data))
|
||
else:
|
||
tf.add(str(file_path), arcname=arcname)
|
||
return buf.getvalue()
|
||
|
||
|
||
def _resolve_bundle_env_vars(source: Path) -> dict[str, str]:
|
||
"""
|
||
Expand ``${VAR}`` references in YAML files that contain
|
||
secrets, using the client's environment.
|
||
|
||
Returns a mapping of ``arcname → resolved YAML text`` for
|
||
files that were modified. Files without env var references
|
||
are omitted (bundled as-is).
|
||
|
||
Expanded fields:
|
||
|
||
- ``config.yaml``: ``llm.connection.*`` and
|
||
``executor.connection.*`` values, ``executor.auth``
|
||
``api_key`` / ``base_url`` (when ``type: api_key``), and
|
||
``tools.builtins[*]`` dict-entry values (except ``name``)
|
||
- ``tools/mcp/*.yaml``: ``headers.*`` and ``env.*`` values
|
||
|
||
These mirror the server-side parser's ``${VAR}`` expansion
|
||
sites. Resolving here, against the client's own environment,
|
||
is what keeps secrets working now that the server refuses to
|
||
expand tenant-uploaded bundles against its process env.
|
||
|
||
:param source: The agent image directory.
|
||
:returns: ``{arcname: resolved_yaml_text}`` for files
|
||
that had env vars expanded.
|
||
:raises OmnigentError: If a ``${VAR}`` reference
|
||
cannot be resolved from the environment.
|
||
"""
|
||
from omnigent.spec import expand_env_vars
|
||
|
||
resolved: dict[str, str] = {}
|
||
|
||
# ── config.yaml ──────────────────────────────────
|
||
config_path = source / "config.yaml"
|
||
if config_path.exists():
|
||
raw = yaml.safe_load(config_path.read_text())
|
||
if isinstance(raw, dict):
|
||
changed = _expand_config_env_vars(raw, expand_env_vars)
|
||
if changed:
|
||
resolved["config.yaml"] = yaml.dump(
|
||
raw,
|
||
default_flow_style=False,
|
||
)
|
||
|
||
# ── tools/mcp/*.yaml ─────────────────────────────
|
||
# ``headers`` (HTTP transport auth) and ``env`` (stdio transport
|
||
# process env) are both secret-bearing and both expanded by the
|
||
# server-side parser, so resolve both client-side.
|
||
mcp_dir = source / "tools" / "mcp"
|
||
if mcp_dir.is_dir():
|
||
for yaml_file in sorted(mcp_dir.glob("*.yaml")):
|
||
raw = yaml.safe_load(yaml_file.read_text())
|
||
if not isinstance(raw, dict):
|
||
continue
|
||
changed = False
|
||
for field in ("headers", "env"):
|
||
value = raw.get(field)
|
||
if isinstance(value, dict):
|
||
raw[field] = expand_env_vars(
|
||
{str(k): str(v) for k, v in value.items()},
|
||
)
|
||
changed = True
|
||
if changed:
|
||
arcname = str(yaml_file.relative_to(source))
|
||
resolved[arcname] = yaml.dump(
|
||
raw,
|
||
default_flow_style=False,
|
||
)
|
||
|
||
return resolved
|
||
|
||
|
||
class _LLMDeploy(BaseModel): # type: ignore[explicit-any] # Pydantic extra="allow" stubs use Any
|
||
"""
|
||
Pydantic model for the ``llm:`` block during deploy-time
|
||
env var expansion.
|
||
|
||
:param connection: Key-value pairs for LLM connection
|
||
config, e.g. ``{"api_key": "${OPENAI_API_KEY}"}``.
|
||
"""
|
||
|
||
model_config = ConfigDict(extra="allow")
|
||
connection: dict[str, str] | None = None
|
||
|
||
|
||
class _BuiltinEntry(BaseModel): # type: ignore[explicit-any] # Pydantic extra="allow" stubs use Any
|
||
"""
|
||
Pydantic model for a single dict entry in
|
||
``tools.builtins`` during deploy-time env var expansion.
|
||
|
||
:param name: The built-in tool name, e.g.
|
||
``"web_search"``.
|
||
"""
|
||
|
||
model_config = ConfigDict(extra="allow")
|
||
name: str
|
||
|
||
|
||
class _ToolsDeploy(BaseModel): # type: ignore[explicit-any] # builtins field is list[str | dict[str, Any]]
|
||
"""
|
||
Pydantic model for the ``tools:`` block during deploy-time
|
||
env var expansion.
|
||
|
||
:param builtins: Mixed list of string tool names and dict
|
||
entries with config fields, e.g.
|
||
``["web_search", {"name": "web_search",
|
||
"api_key": "${KEY}"}]``.
|
||
"""
|
||
|
||
model_config = ConfigDict(extra="allow")
|
||
builtins: list[str | dict[str, Any]] | None = None # type: ignore[explicit-any]
|
||
|
||
|
||
class _ExecutorDeploy(BaseModel): # type: ignore[explicit-any] # auth is a free-form mapping
|
||
"""
|
||
Pydantic model for the ``executor:`` block during deploy-time
|
||
env var expansion.
|
||
|
||
Mirrors the secret-bearing fields the server-side parser
|
||
expands (``omnigent/spec/parser.py`` — ``_parse_executor`` /
|
||
``_parse_executor_auth``): the ``connection`` dict and, for
|
||
``auth.type == "api_key"``, the ``api_key`` / ``base_url``
|
||
values. Resolving these client-side keeps ``${VAR}`` working
|
||
for operator specs now that the server no longer expands
|
||
tenant bundles.
|
||
|
||
:param connection: Key-value pairs for executor connection
|
||
config, e.g. ``{"api_key": "${OPENAI_API_KEY}"}``.
|
||
:param auth: The ``auth:`` mapping, e.g.
|
||
``{"type": "api_key", "api_key": "${OPENAI_API_KEY}"}``.
|
||
Only expanded when ``type == "api_key"``.
|
||
"""
|
||
|
||
model_config = ConfigDict(extra="allow")
|
||
connection: dict[str, str] | None = None
|
||
auth: dict[str, Any] | None = None # type: ignore[explicit-any]
|
||
|
||
|
||
class _DeployConfig(BaseModel): # type: ignore[explicit-any] # Pydantic extra="allow" stubs use Any
|
||
"""
|
||
Pydantic model for the top-level config.yaml structure
|
||
during deploy-time env var expansion.
|
||
|
||
Only the fields containing secrets (``llm``, ``executor``,
|
||
``tools``) are modeled; all other fields pass through via
|
||
``extra="allow"``.
|
||
|
||
:param llm: The LLM configuration block, or ``None``
|
||
if absent.
|
||
:param executor: The executor configuration block, or
|
||
``None`` if absent.
|
||
:param tools: The tools configuration block, or ``None``
|
||
if absent.
|
||
"""
|
||
|
||
model_config = ConfigDict(extra="allow")
|
||
llm: _LLMDeploy | None = None
|
||
executor: _ExecutorDeploy | None = None
|
||
tools: _ToolsDeploy | None = None
|
||
|
||
|
||
def _expand_config_env_vars( # type: ignore[explicit-any] # raw is parsed YAML (heterogeneous values)
|
||
raw: dict[str, Any],
|
||
expand_fn: Callable[[dict[str, str]], dict[str, str]],
|
||
) -> bool:
|
||
"""
|
||
Expand ``${VAR}`` references in-place in a parsed
|
||
``config.yaml`` dict. Returns ``True`` if any field
|
||
was expanded.
|
||
|
||
Expanded fields (mirrors the server-side parser's expansion
|
||
sites so operator specs resolve identically client-side now
|
||
that the server no longer expands tenant bundles):
|
||
|
||
- ``llm.connection`` — all values
|
||
- ``executor.connection`` — all values
|
||
- ``executor.auth`` — ``api_key`` / ``base_url`` when
|
||
``type == "api_key"``
|
||
- ``tools.builtins[*]`` — dict-entry values except ``name``
|
||
|
||
:param raw: The parsed config.yaml dict (modified in-place).
|
||
:param expand_fn: Callable that expands env var references
|
||
in a string-to-string dict, e.g.
|
||
:func:`omnigent.spec.expand_env_vars`.
|
||
:returns: ``True`` if any values were expanded.
|
||
"""
|
||
cfg = _DeployConfig.model_validate(raw)
|
||
changed = False
|
||
|
||
if cfg.llm is not None and cfg.llm.connection is not None:
|
||
raw["llm"]["connection"] = expand_fn(cfg.llm.connection)
|
||
changed = True
|
||
|
||
if cfg.executor is not None and cfg.executor.connection is not None:
|
||
raw["executor"]["connection"] = expand_fn(cfg.executor.connection)
|
||
changed = True
|
||
|
||
# ``executor.auth`` with ``type: api_key`` — only ``api_key`` and
|
||
# ``base_url`` are secret-bearing (matches _parse_executor_auth).
|
||
if (
|
||
cfg.executor is not None
|
||
and cfg.executor.auth is not None
|
||
and cfg.executor.auth.get("type") == "api_key"
|
||
):
|
||
auth_secrets = {
|
||
k: str(cfg.executor.auth[k])
|
||
for k in ("api_key", "base_url")
|
||
if cfg.executor.auth.get(k) is not None
|
||
}
|
||
if auth_secrets:
|
||
raw["executor"]["auth"].update(expand_fn(auth_secrets))
|
||
changed = True
|
||
|
||
if cfg.tools is not None and cfg.tools.builtins is not None:
|
||
changed = (
|
||
_expand_builtin_env_vars(
|
||
raw["tools"]["builtins"],
|
||
cfg.tools.builtins,
|
||
expand_fn,
|
||
)
|
||
or changed
|
||
)
|
||
|
||
return changed
|
||
|
||
|
||
def _expand_builtin_env_vars( # type: ignore[explicit-any] # entries are parsed YAML dicts
|
||
raw_builtins: list[str | dict[str, Any]],
|
||
parsed_builtins: list[str | dict[str, Any]],
|
||
expand_fn: Callable[[dict[str, str]], dict[str, str]],
|
||
) -> bool:
|
||
"""
|
||
Expand ``${VAR}`` references in dict entries of
|
||
``tools.builtins``, modifying *raw_builtins* in-place.
|
||
|
||
String entries are skipped (no config to expand). Dict
|
||
entries have all fields except ``name`` expanded.
|
||
|
||
:param raw_builtins: The mutable builtins list from the
|
||
raw config dict (modified in-place).
|
||
:param parsed_builtins: The Pydantic-parsed builtins list
|
||
used for typed access.
|
||
:param expand_fn: Callable that expands env var references
|
||
in a string-to-string dict.
|
||
:returns: ``True`` if any values were expanded.
|
||
"""
|
||
changed = False
|
||
for i, entry in enumerate(parsed_builtins):
|
||
if not isinstance(entry, dict):
|
||
continue
|
||
parsed = _BuiltinEntry.model_validate(entry)
|
||
# Extra fields are the tool-specific config (api_key, etc.).
|
||
config_fields = (
|
||
{str(k): str(v) for k, v in parsed.model_extra.items()} if parsed.model_extra else {}
|
||
)
|
||
if config_fields:
|
||
expanded = expand_fn(config_fields)
|
||
raw_builtins[i] = {"name": parsed.name, **expanded}
|
||
changed = True
|
||
return changed
|
||
|
||
|
||
# Click ``flag_value`` for bare ``--resume`` (no arg). Must exist
|
||
# before any command's decorator evaluates.
|
||
_RESUME_PICKER_SENTINEL = "__resume_picker__"
|
||
|
||
|
||
def _reject_native_on_windows(harness: str) -> None:
|
||
"""Fail a native (tmux/PTY) harness command with an actionable message.
|
||
|
||
The ``omnigent claude`` / ``codex`` / ``cursor`` native wrappers drive a
|
||
private tmux server and PTY, which don't exist on Windows. Point users at
|
||
the SDK harnesses / web UI instead of letting them hit a tmux crash.
|
||
|
||
:param harness: The native command name, e.g. ``"claude"``.
|
||
:raises click.ClickException: Always, when running on Windows.
|
||
"""
|
||
if IS_WINDOWS:
|
||
raise click.ClickException(
|
||
f"`omnigent {harness}` (native tmux/PTY terminal) is not supported on "
|
||
"Windows. Use an SDK-based harness via `omnigent run <agent.yaml>` "
|
||
"or the web UI."
|
||
)
|
||
|
||
|
||
@cli.command(
|
||
context_settings={
|
||
"ignore_unknown_options": True,
|
||
"allow_extra_args": True,
|
||
}
|
||
)
|
||
@click.option(
|
||
"--server",
|
||
default=None,
|
||
help=(
|
||
"Remote omnigent URL. Starts a local runner, binds the session, "
|
||
"launches Claude in a terminal resource, and attaches this TTY. "
|
||
'Pass --server "" to auto-spawn a persistent local server in the '
|
||
"background and use that instead of a remote one."
|
||
),
|
||
)
|
||
@click.option(
|
||
"-r",
|
||
"--resume",
|
||
"resume",
|
||
is_flag=False,
|
||
flag_value=_RESUME_PICKER_SENTINEL,
|
||
default=None,
|
||
help=(
|
||
"Resume a prior Omnigent conversation. With a conversation id "
|
||
"(e.g. ``--resume conv_abc123``) attaches directly; with no value "
|
||
"opens an interactive picker scoped to claude-native sessions."
|
||
),
|
||
)
|
||
@click.option(
|
||
"--session",
|
||
"session_id",
|
||
metavar="SESSION_ID",
|
||
default=None,
|
||
hidden=True,
|
||
help="Deprecated alias for ``--resume <id>``; kept for one release.",
|
||
)
|
||
@click.option(
|
||
"--host",
|
||
"register_host",
|
||
is_flag=True,
|
||
default=False,
|
||
help=(
|
||
"Register this machine as a host (inline equivalent of `omnigent host`). "
|
||
"Requires --server."
|
||
),
|
||
)
|
||
@click.option(
|
||
"--use-native-config",
|
||
"use_claude_config",
|
||
is_flag=True,
|
||
default=False,
|
||
help=(
|
||
"Use your existing Claude Code configuration instead of Databricks auth. "
|
||
"When set, any configured provider is ignored and Claude "
|
||
"authenticates via its own ``~/.claude/`` settings."
|
||
),
|
||
)
|
||
@click.option(
|
||
"--profile-startup",
|
||
"profile_startup",
|
||
is_flag=True,
|
||
default=False,
|
||
help=(
|
||
"Print native Claude startup timing marks to stderr. Also enabled by "
|
||
f"{_CLAUDE_STARTUP_PROFILE_ENV_VAR}=1."
|
||
),
|
||
)
|
||
@click.option(
|
||
"--command",
|
||
"claude_command",
|
||
default=None,
|
||
metavar="CMD",
|
||
help=(
|
||
"Claude Code CLI executable to run. "
|
||
"Defaults to ``claude``. Use this when a wrapper binary replaces the "
|
||
"``claude`` CLI while preserving its interface (e.g. a custom launcher "
|
||
"that injects auth or environment before delegating to ``claude``)."
|
||
),
|
||
)
|
||
@click.argument("claude_args", nargs=-1, type=click.UNPROCESSED)
|
||
def claude(
|
||
server: str | None,
|
||
resume: str | None,
|
||
session_id: str | None,
|
||
register_host: bool,
|
||
use_claude_config: bool,
|
||
profile_startup: bool,
|
||
claude_command: str | None,
|
||
claude_args: tuple[str, ...],
|
||
) -> None:
|
||
# Param docs live in comments — Click uses the docstring for --help.
|
||
# :param server: Remote Omnigent server URL, or None for local.
|
||
# :param resume: None, picker sentinel, or a conversation id.
|
||
# :param session_id: Legacy ``--session`` id; mutually exclusive with ``--resume``.
|
||
# :param use_claude_config: When True, skip ucode/Databricks auth and use
|
||
# existing Claude config.
|
||
# :param profile_startup: When True, print startup timing marks.
|
||
# :param claude_args: Pass-through args for ``claude``.
|
||
"""Launch Claude Code in an Omnigent terminal.
|
||
|
||
\b
|
||
Examples:
|
||
omnigent claude
|
||
omnigent claude --resume conv_abc123
|
||
omnigent claude --resume # interactive picker
|
||
omnigent claude --server https://<app>.databricksapps.com
|
||
"""
|
||
_reject_native_on_windows("claude")
|
||
startup_profiler = StartupProfiler.from_env(
|
||
name="omnigent claude",
|
||
env_var=_CLAUDE_STARTUP_PROFILE_ENV_VAR,
|
||
explicit=profile_startup,
|
||
)
|
||
startup_profiler.mark("cli entered")
|
||
|
||
# Apply config defaults (same as ``run`` does).
|
||
cfg = _load_effective_config()
|
||
if server is None:
|
||
server = cfg.get("server")
|
||
auto_open_conversation = _resolve_auto_open_conversation_from_config(cfg)
|
||
startup_profiler.mark("config resolved")
|
||
|
||
# Validate option combinations BEFORE any side effects (daemon
|
||
# spawn, server discovery). Calling _ensure_backend first would
|
||
# mean a bad arg pair waits the full local-server-discover
|
||
# timeout (60s in CI) before surfacing the UsageError, which
|
||
# the test_claude_command_session_and_resume_mutually_exclusive
|
||
# regression caught in CI.
|
||
del register_host
|
||
choice = _split_resume_value(resume)
|
||
if session_id is not None and (choice.picker or choice.conversation_id is not None):
|
||
raise click.UsageError(
|
||
"--session and --resume are mutually exclusive; "
|
||
"prefer --resume (--session is deprecated).",
|
||
)
|
||
startup_profiler.mark("arguments validated")
|
||
|
||
# Ensure the host daemon (local when ``--server`` is omitted/empty,
|
||
# remote otherwise) and resolve the concrete Omnigent server URL. The daemon
|
||
# owns the runner; the CLI only connects. ``--host`` is now redundant
|
||
# (the daemon is always ensured) and kept only as a no-op for scripts.
|
||
startup_profiler.mark("ensuring backend")
|
||
server = _ensure_backend(server)
|
||
startup_profiler.mark("backend ready", detail=f"server={server}")
|
||
|
||
resolved_session_id = (
|
||
choice.conversation_id if choice.conversation_id is not None else session_id
|
||
)
|
||
|
||
from omnigent.claude_native import run_claude_native
|
||
|
||
startup_profiler.mark("native module imported")
|
||
|
||
run_claude_native(
|
||
server=server,
|
||
session_id=resolved_session_id,
|
||
resume_picker=choice.picker,
|
||
claude_args=claude_args,
|
||
use_claude_config=use_claude_config,
|
||
auto_open_conversation=auto_open_conversation,
|
||
startup_profiler=startup_profiler,
|
||
**({"command": claude_command} if claude_command else {}),
|
||
)
|
||
|
||
|
||
@cli.command(
|
||
context_settings={
|
||
"ignore_unknown_options": True,
|
||
"allow_extra_args": True,
|
||
}
|
||
)
|
||
@click.option(
|
||
"--server",
|
||
default=None,
|
||
help=(
|
||
"Remote omnigent URL. Ensures the host daemon, asks the "
|
||
"daemon-spawned runner to launch Codex, and attaches this TTY. "
|
||
'Pass --server "" to auto-spawn a persistent local server in the '
|
||
"background and use that instead of a remote one."
|
||
),
|
||
)
|
||
@click.option(
|
||
"-r",
|
||
"--resume",
|
||
"resume",
|
||
is_flag=False,
|
||
flag_value=_RESUME_PICKER_SENTINEL,
|
||
default=None,
|
||
help=(
|
||
"Resume a prior Omnigent conversation. With a conversation id "
|
||
"(e.g. ``--resume conv_abc123``) attaches directly; with no value "
|
||
"opens an interactive picker scoped to codex-native sessions."
|
||
),
|
||
)
|
||
@click.option(
|
||
"--session",
|
||
"session_id",
|
||
metavar="SESSION_ID",
|
||
default=None,
|
||
hidden=True,
|
||
help="Deprecated alias for ``--resume <id>``; kept for one release.",
|
||
)
|
||
@click.option("--model", default=None, help="Codex model to use for the native thread.")
|
||
@click.option(
|
||
"-p",
|
||
"--prompt",
|
||
default=None,
|
||
help="Send this as the first message after the Codex TUI starts.",
|
||
)
|
||
@click.argument("codex_args", nargs=-1, type=click.UNPROCESSED)
|
||
def codex(
|
||
server: str | None,
|
||
resume: str | None,
|
||
session_id: str | None,
|
||
model: str | None,
|
||
prompt: str | None,
|
||
codex_args: tuple[str, ...],
|
||
) -> None:
|
||
# Param docs live in comments — Click uses the docstring for --help.
|
||
# :param server: Remote Omnigent server URL, or None for local.
|
||
# :param resume: None, picker sentinel, or a conversation id.
|
||
# :param session_id: Legacy ``--session`` id; mutually exclusive with ``--resume``.
|
||
# :param model: Codex model id.
|
||
# :param prompt: Optional first prompt.
|
||
# :param codex_args: Pass-through args for ``codex`` before ``resume``.
|
||
"""Launch Codex TUI in an Omnigent terminal.
|
||
|
||
\b
|
||
Examples:
|
||
omnigent codex
|
||
omnigent codex --resume conv_abc123
|
||
omnigent codex --resume # interactive picker
|
||
omnigent codex --server https://<app>.databricksapps.com
|
||
"""
|
||
_reject_native_on_windows("codex")
|
||
choice = _split_resume_value(resume)
|
||
if session_id is not None and (choice.picker or choice.conversation_id is not None):
|
||
raise click.UsageError(
|
||
"--session and --resume are mutually exclusive; "
|
||
"prefer --resume (--session is deprecated).",
|
||
)
|
||
|
||
from omnigent.codex_native import run_codex_native
|
||
|
||
cfg = _load_effective_config()
|
||
if server is None:
|
||
server = cfg.get("server")
|
||
if model is None:
|
||
model = cfg.get("model")
|
||
auto_open_conversation = _resolve_auto_open_conversation_from_config(cfg)
|
||
|
||
# Validate option combinations before any side effects — see
|
||
# the same comment in the claude command. _ensure_backend can
|
||
# spawn the daemon and take the full local-server-discover
|
||
# timeout to fail, which would make a bad arg pair look like
|
||
# a backend outage instead of a usage error.
|
||
choice = _split_resume_value(resume)
|
||
if session_id is not None and (choice.picker or choice.conversation_id is not None):
|
||
raise click.UsageError(
|
||
"--session and --resume are mutually exclusive; "
|
||
"prefer --resume (--session is deprecated).",
|
||
)
|
||
|
||
# Ensure the host daemon (local when ``--server`` is omitted/empty,
|
||
# remote otherwise) and resolve the concrete Omnigent server URL. Codex follows
|
||
# the same ownership model as attach/run/claude: the daemon-spawned runner
|
||
# owns the app-server and TUI; the CLI attaches to the tmux terminal.
|
||
server = _ensure_backend(server)
|
||
|
||
resolved_session_id = (
|
||
choice.conversation_id if choice.conversation_id is not None else session_id
|
||
)
|
||
|
||
run_codex_native(
|
||
server=server,
|
||
session_id=resolved_session_id,
|
||
resume_picker=choice.picker,
|
||
codex_args=codex_args,
|
||
model=model,
|
||
prompt=prompt,
|
||
auto_open_conversation=auto_open_conversation,
|
||
)
|
||
|
||
|
||
@cli.command(
|
||
context_settings={
|
||
"ignore_unknown_options": True,
|
||
"allow_extra_args": True,
|
||
}
|
||
)
|
||
@click.option(
|
||
"--server",
|
||
default=None,
|
||
help=(
|
||
"Remote omnigent URL. Ensures the host daemon, asks the "
|
||
"daemon-spawned runner to launch OpenCode, and attaches this TTY. "
|
||
'Pass --server "" to auto-spawn a persistent local server in the '
|
||
"background and use that instead of a remote one."
|
||
),
|
||
)
|
||
@click.option(
|
||
"-r",
|
||
"--resume",
|
||
"resume",
|
||
is_flag=False,
|
||
flag_value=_RESUME_PICKER_SENTINEL,
|
||
default=None,
|
||
help=(
|
||
"Resume a prior Omnigent conversation. With a conversation id "
|
||
"(e.g. ``--resume conv_abc123``) attaches directly; with no value "
|
||
"opens an interactive picker scoped to opencode-native sessions."
|
||
),
|
||
)
|
||
@click.option(
|
||
"--session",
|
||
"session_id",
|
||
metavar="SESSION_ID",
|
||
default=None,
|
||
hidden=True,
|
||
help="Deprecated alias for ``--resume <id>``; kept for one release.",
|
||
)
|
||
@click.option("--model", default=None, help="OpenCode model to use for the native session.")
|
||
@click.argument("opencode_args", nargs=-1, type=click.UNPROCESSED)
|
||
def opencode(
|
||
server: str | None,
|
||
resume: str | None,
|
||
session_id: str | None,
|
||
model: str | None,
|
||
opencode_args: tuple[str, ...],
|
||
) -> None:
|
||
# :param server: Remote Omnigent server URL, or None for local.
|
||
# :param resume: None, picker sentinel, or a conversation id.
|
||
# :param session_id: Legacy ``--session`` id; mutually exclusive with ``--resume``.
|
||
# :param model: OpenCode model id pinned on the wrapper spec.
|
||
# :param opencode_args: Pass-through args persisted for the ``opencode attach`` TUI.
|
||
"""Launch OpenCode TUI in an Omnigent terminal.
|
||
|
||
\b
|
||
Examples:
|
||
omnigent opencode
|
||
omnigent opencode --resume conv_abc123
|
||
omnigent opencode --resume # interactive picker
|
||
omnigent opencode --server https://<app>.databricksapps.com
|
||
"""
|
||
from omnigent.opencode_native import run_opencode_native
|
||
|
||
cfg = _load_effective_config()
|
||
if server is None:
|
||
server = cfg.get("server")
|
||
if model is None:
|
||
# Prefer the OpenCode-specific default (set in `omni setup` → OpenCode →
|
||
# "Set default model"); fall back to the shared `model` key for back-compat.
|
||
model = cfg.get("opencode_model") or cfg.get("model")
|
||
auto_open_conversation = _resolve_auto_open_conversation_from_config(cfg)
|
||
|
||
# Validate option combinations before any side effects (see the codex
|
||
# command): _ensure_backend can spawn the daemon and take the full
|
||
# local-server-discover timeout, which would mask a bad arg pair as an
|
||
# outage instead of a usage error.
|
||
choice = _split_resume_value(resume)
|
||
if session_id is not None and (choice.picker or choice.conversation_id is not None):
|
||
raise click.UsageError(
|
||
"--session and --resume are mutually exclusive; "
|
||
"prefer --resume (--session is deprecated).",
|
||
)
|
||
|
||
# Ensure the host daemon (local when ``--server`` is omitted/empty, remote
|
||
# otherwise); the daemon-spawned runner owns ``opencode serve`` + the TUI,
|
||
# and this CLI attaches to the tmux terminal.
|
||
server = _ensure_backend(server)
|
||
resolved_session_id = (
|
||
choice.conversation_id if choice.conversation_id is not None else session_id
|
||
)
|
||
run_opencode_native(
|
||
server=server,
|
||
session_id=resolved_session_id,
|
||
resume_picker=choice.picker,
|
||
opencode_args=opencode_args,
|
||
model=model,
|
||
auto_open_conversation=auto_open_conversation,
|
||
)
|
||
|
||
|
||
@cli.command(
|
||
context_settings={
|
||
"ignore_unknown_options": True,
|
||
"allow_extra_args": True,
|
||
}
|
||
)
|
||
@click.option(
|
||
"--server",
|
||
default=None,
|
||
help=(
|
||
"Remote omnigent URL. Ensures the host daemon, asks the "
|
||
"daemon-spawned runner to launch Pi, and attaches this TTY. "
|
||
'Pass --server "" to auto-spawn a persistent local server in the '
|
||
"background and use that instead of a remote one."
|
||
),
|
||
)
|
||
@click.option(
|
||
"-r",
|
||
"--resume",
|
||
"resume",
|
||
is_flag=False,
|
||
flag_value=_RESUME_PICKER_SENTINEL,
|
||
default=None,
|
||
help=(
|
||
"Resume a prior Omnigent conversation. With a conversation id "
|
||
"(e.g. ``--resume conv_abc123``) attaches directly; with no value "
|
||
"opens an interactive picker scoped to pi-native sessions."
|
||
),
|
||
)
|
||
@click.option(
|
||
"--session",
|
||
"session_id",
|
||
metavar="SESSION_ID",
|
||
default=None,
|
||
hidden=True,
|
||
help="Deprecated alias for ``--resume <id>``; kept for one release.",
|
||
)
|
||
@click.argument("pi_args", nargs=-1, type=click.UNPROCESSED)
|
||
def pi(
|
||
server: str | None,
|
||
resume: str | None,
|
||
session_id: str | None,
|
||
pi_args: tuple[str, ...],
|
||
) -> None:
|
||
"""Launch Pi TUI in an Omnigent terminal.
|
||
|
||
\b
|
||
Examples:
|
||
omnigent pi
|
||
omnigent pi --resume conv_abc123
|
||
omnigent pi --resume # interactive picker
|
||
omnigent pi --model local-deepseek/deepseek-v4-flash
|
||
"""
|
||
choice = _split_resume_value(resume)
|
||
if session_id is not None and (choice.picker or choice.conversation_id is not None):
|
||
raise click.UsageError(
|
||
"--session and --resume are mutually exclusive; "
|
||
"prefer --resume (--session is deprecated).",
|
||
)
|
||
|
||
from omnigent.pi_native import run_pi_native
|
||
|
||
cfg = _load_effective_config()
|
||
if server is None:
|
||
server = cfg.get("server")
|
||
auto_open_conversation = _resolve_auto_open_conversation_from_config(cfg)
|
||
|
||
server = _ensure_backend(server)
|
||
resolved_session_id = (
|
||
choice.conversation_id if choice.conversation_id is not None else session_id
|
||
)
|
||
|
||
run_pi_native(
|
||
server=server,
|
||
session_id=resolved_session_id,
|
||
resume_picker=choice.picker,
|
||
pi_args=pi_args,
|
||
auto_open_conversation=auto_open_conversation,
|
||
)
|
||
|
||
|
||
def _bundled_agent_brain_harness(name: str) -> str | None:
|
||
"""Return the canonical brain harness of a bundled agent, or ``None``.
|
||
|
||
Reads the brain harness (``executor.config.harness``, falling back to
|
||
``executor.harness`` / ``executor.type``) from the bundled agent's
|
||
``config.yaml`` — e.g. polly's and debby's ``claude-sdk`` brain — so
|
||
credential fallback can target the model family the brain actually
|
||
runs on. Mirrors :func:`_peek_default_agent_harness`'s YAML-reading
|
||
style.
|
||
|
||
:param name: Bundled example directory name, e.g. ``"polly"``.
|
||
:returns: The canonical harness id, e.g. ``"claude-sdk"``, or ``None``
|
||
when the bundle is missing/unreadable or declares no brain harness.
|
||
"""
|
||
config_path = Path(_bundled_example_path(name)) / "config.yaml"
|
||
if not config_path.is_file():
|
||
return None
|
||
try:
|
||
raw = yaml.safe_load(config_path.read_text()) or {}
|
||
except (OSError, yaml.YAMLError):
|
||
return None
|
||
if not isinstance(raw, dict):
|
||
return None
|
||
executor = raw.get("executor")
|
||
if not isinstance(executor, dict):
|
||
return None
|
||
declared: object = None
|
||
config_block = executor.get("config")
|
||
if isinstance(config_block, dict):
|
||
declared = config_block.get("harness")
|
||
if not isinstance(declared, str) or not declared:
|
||
declared = executor.get("harness") or executor.get("type")
|
||
if not isinstance(declared, str) or not declared:
|
||
return None
|
||
return canonicalize_harness(declared) or declared
|
||
|
||
|
||
def _ensure_bundled_agent_brain_credential(name: str) -> None:
|
||
"""Ensure the bundled agent's brain harness has a credential to launch with.
|
||
|
||
Polly and Debby launch with the *first available* credential for their
|
||
brain's model family rather than requiring a specific one to be marked
|
||
``default: true`` up front — so users can start without manually
|
||
picking/configuring one. When no default provider is configured for the
|
||
agent's brain harness, pick the first available credential serving that
|
||
family and mark it the default so the downstream ``run`` resolves it —
|
||
printing a notice (to stderr) since this mutates the user's config on a
|
||
launch command, mirroring the confirmation ``setup`` / ``/model`` show.
|
||
|
||
No-op when a default is already configured, or when no credential is
|
||
available for the family (the harness raises its own launch error then).
|
||
Only an explicit default (or none) is touched — an existing default is
|
||
never overridden. Marking the first available credential the default
|
||
mirrors :func:`_add_provider_entry`'s "a first provider just works"
|
||
adoption (see :func:`omnigent.setup`).
|
||
|
||
:param name: Bundled example directory name, e.g. ``"polly"``.
|
||
"""
|
||
from omnigent.errors import OmnigentError
|
||
from omnigent.onboarding.configure_models import family_label
|
||
from omnigent.onboarding.detected import effective_config_with_detected
|
||
from omnigent.onboarding.provider_config import (
|
||
default_provider_for_harness,
|
||
harness_family,
|
||
load_config,
|
||
load_providers,
|
||
provider_families,
|
||
set_default_provider,
|
||
)
|
||
|
||
brain_harness = _bundled_agent_brain_harness(name)
|
||
if brain_harness is None:
|
||
return
|
||
family = harness_family(brain_harness)
|
||
if family is None:
|
||
return
|
||
# Best-effort: adopting a default must never crash a launch. Any malformed
|
||
# or unexpected config state (corrupt YAML, ambiguous defaults, a divergent
|
||
# on-disk entry) degrades to a no-op — the harness then raises its own
|
||
# credential error.
|
||
try:
|
||
config = effective_config_with_detected(load_config())
|
||
if default_provider_for_harness(config, brain_harness) is not None:
|
||
return
|
||
on_disk = _load_global_config()
|
||
disk_block = on_disk.get("providers") if isinstance(on_disk, dict) else None
|
||
if not isinstance(disk_block, dict):
|
||
return
|
||
# Skip ambient-detected entries (not on disk) — auto-defaulted upstream.
|
||
for entry_name, entry in load_providers(config).items():
|
||
if family not in provider_families(entry) or entry_name not in disk_block:
|
||
continue
|
||
_save_global_config(
|
||
{"providers": set_default_provider(disk_block, entry_name, family)}
|
||
)
|
||
# Announce: this mutates the user's config on a launch command.
|
||
click.echo(
|
||
f"No default {family_label(family)} credential set — "
|
||
f"using {_credential_label(entry_name, entry)} and saving it as "
|
||
f"the default (change anytime with: omnigent /model).",
|
||
err=True,
|
||
)
|
||
return
|
||
except (OSError, yaml.YAMLError, OmnigentError):
|
||
return
|
||
|
||
|
||
@cli.command(
|
||
context_settings={
|
||
"ignore_unknown_options": True,
|
||
"allow_extra_args": True,
|
||
}
|
||
)
|
||
@click.option(
|
||
"--server",
|
||
default=None,
|
||
help=(
|
||
"Remote omnigent URL. Ensures the host daemon, asks the "
|
||
"daemon-spawned runner to launch the Cursor TUI, and attaches this TTY. "
|
||
'Pass --server "" to auto-spawn a persistent local server in the '
|
||
"background and use that instead of a remote one."
|
||
),
|
||
)
|
||
@click.option(
|
||
"-r",
|
||
"--resume",
|
||
"resume",
|
||
is_flag=False,
|
||
flag_value=_RESUME_PICKER_SENTINEL,
|
||
default=None,
|
||
help=(
|
||
"Resume a prior Omnigent conversation. With a conversation id "
|
||
"(e.g. ``--resume conv_abc123``) attaches directly; with no value "
|
||
"opens an interactive picker scoped to cursor-native sessions."
|
||
),
|
||
)
|
||
@click.option(
|
||
"--session",
|
||
"session_id",
|
||
metavar="SESSION_ID",
|
||
default=None,
|
||
hidden=True,
|
||
help="Deprecated alias for ``--resume <id>``; kept for one release.",
|
||
)
|
||
@click.option(
|
||
"--mode",
|
||
"mode",
|
||
default=None,
|
||
type=click.Choice(["plan", "ask"]),
|
||
help=(
|
||
"Start cursor-agent in the given execution mode. "
|
||
"``plan``: read-only/planning (analyze, propose plans, no edits). "
|
||
"``ask``: Q&A style for explanations and questions (read-only)."
|
||
),
|
||
)
|
||
@click.option(
|
||
"--model",
|
||
default=None,
|
||
help="Cursor model to use for the native TUI (e.g. gpt-5.2, claude-4.6-sonnet-medium).",
|
||
)
|
||
@click.argument("cursor_args", nargs=-1, type=click.UNPROCESSED)
|
||
def cursor(
|
||
server: str | None,
|
||
resume: str | None,
|
||
session_id: str | None,
|
||
mode: str | None,
|
||
model: str | None,
|
||
cursor_args: tuple[str, ...],
|
||
) -> None:
|
||
# Param docs live in comments — Click uses the docstring for --help.
|
||
# :param model: Cursor model id passed to cursor-agent as ``--model``.
|
||
"""Launch the Cursor TUI in an Omnigent terminal.
|
||
|
||
\b
|
||
Examples:
|
||
omnigent cursor
|
||
omnigent cursor --model gpt-5.2
|
||
omnigent cursor --resume conv_abc123
|
||
omnigent cursor --resume # interactive picker
|
||
omnigent cursor --mode plan # start in plan (read-only) mode
|
||
omnigent cursor --mode ask # start in ask (Q&A) mode
|
||
"""
|
||
_reject_native_on_windows("cursor")
|
||
choice = _split_resume_value(resume)
|
||
if session_id is not None and (choice.picker or choice.conversation_id is not None):
|
||
raise click.UsageError(
|
||
"--session and --resume are mutually exclusive; "
|
||
"prefer --resume (--session is deprecated).",
|
||
)
|
||
|
||
from omnigent.cursor_native import run_cursor_native
|
||
|
||
cfg = _load_effective_config()
|
||
if server is None:
|
||
server = cfg.get("server")
|
||
# Deliberately no ``cfg.get("model")`` fallback (unlike ``codex``): the
|
||
# global config model is a Claude/Codex catalog id, not a cursor-agent
|
||
# model id, and pinning it would break the cursor TUI launch. Cursor's
|
||
# model is explicit-only here; persistent selection rides the web /model.
|
||
auto_open_conversation = _resolve_auto_open_conversation_from_config(cfg)
|
||
|
||
server = _ensure_backend(server)
|
||
resolved_session_id = (
|
||
choice.conversation_id if choice.conversation_id is not None else session_id
|
||
)
|
||
|
||
run_cursor_native(
|
||
server=server,
|
||
session_id=resolved_session_id,
|
||
resume_picker=choice.picker,
|
||
cursor_args=cursor_args,
|
||
model=model,
|
||
auto_open_conversation=auto_open_conversation,
|
||
mode=mode,
|
||
)
|
||
|
||
|
||
@cli.command(
|
||
context_settings={
|
||
"ignore_unknown_options": True,
|
||
"allow_extra_args": True,
|
||
}
|
||
)
|
||
@click.option(
|
||
"--server",
|
||
default=None,
|
||
help=(
|
||
"Remote omnigent URL. Ensures the host daemon, asks the "
|
||
"daemon-spawned runner to launch the Kiro TUI, and attaches this TTY. "
|
||
'Pass --server "" to auto-spawn a persistent local server in the '
|
||
"background and use that instead of a remote one."
|
||
),
|
||
)
|
||
@click.option(
|
||
"-r",
|
||
"--resume",
|
||
"resume",
|
||
is_flag=False,
|
||
flag_value=_RESUME_PICKER_SENTINEL,
|
||
default=None,
|
||
help=(
|
||
"Resume a prior Omnigent conversation. With a conversation id "
|
||
"(e.g. ``--resume conv_abc123``) attaches directly; with no value "
|
||
"opens an interactive picker scoped to kiro-native sessions."
|
||
),
|
||
)
|
||
@click.option(
|
||
"--session",
|
||
"session_id",
|
||
metavar="SESSION_ID",
|
||
default=None,
|
||
hidden=True,
|
||
help="Deprecated alias for ``--resume <id>``; kept for one release.",
|
||
)
|
||
@click.option("--model", default=None, help="Kiro model to use for the native chat.")
|
||
@click.option("--effort", default=None, help="Kiro effort level to use for the native chat.")
|
||
@click.option("--agent", "kiro_agent", default=None, help="Kiro agent to use for the native chat.")
|
||
@click.option(
|
||
"--trust-tools",
|
||
"trust_tools",
|
||
multiple=True,
|
||
metavar="TOOL",
|
||
help="Trust a specific Kiro tool. May be passed multiple times.",
|
||
)
|
||
@click.option(
|
||
"--trust-all-tools",
|
||
is_flag=True,
|
||
default=False,
|
||
help="Explicitly trust all Kiro tools for this local launch.",
|
||
)
|
||
@click.option(
|
||
"-p",
|
||
"--prompt",
|
||
default=None,
|
||
help="Send this as the initial Kiro chat input when the TUI starts.",
|
||
)
|
||
@click.argument("kiro_args", nargs=-1, type=click.UNPROCESSED)
|
||
def kiro(
|
||
server: str | None,
|
||
resume: str | None,
|
||
session_id: str | None,
|
||
model: str | None,
|
||
effort: str | None,
|
||
kiro_agent: str | None,
|
||
trust_tools: tuple[str, ...],
|
||
trust_all_tools: bool,
|
||
prompt: str | None,
|
||
kiro_args: tuple[str, ...],
|
||
) -> None:
|
||
"""Launch the Kiro TUI in an Omnigent terminal.
|
||
|
||
\b
|
||
Examples:
|
||
omnigent kiro
|
||
omnigent kiro --resume conv_abc123
|
||
omnigent kiro --resume # interactive picker
|
||
omnigent kiro --model auto -p "review this repo"
|
||
"""
|
||
choice = _split_resume_value(resume)
|
||
if session_id is not None and (choice.picker or choice.conversation_id is not None):
|
||
raise click.UsageError(
|
||
"--session and --resume are mutually exclusive; "
|
||
"prefer --resume (--session is deprecated).",
|
||
)
|
||
_reject_reserved_kiro_resume_args(kiro_args)
|
||
|
||
from omnigent.kiro_native import run_kiro_native
|
||
|
||
cfg = _load_effective_config()
|
||
if server is None:
|
||
server = cfg.get("server")
|
||
if model is None:
|
||
model = cfg.get("model")
|
||
auto_open_conversation = _resolve_auto_open_conversation_from_config(cfg)
|
||
launch_args = _build_kiro_launch_args(
|
||
effort=effort,
|
||
kiro_agent=kiro_agent,
|
||
trust_tools=trust_tools,
|
||
trust_all_tools=trust_all_tools,
|
||
passthrough_args=kiro_args,
|
||
)
|
||
|
||
server = _ensure_backend(server)
|
||
resolved_session_id = (
|
||
choice.conversation_id if choice.conversation_id is not None else session_id
|
||
)
|
||
|
||
run_kiro_native(
|
||
server=server,
|
||
session_id=resolved_session_id,
|
||
resume_picker=choice.picker,
|
||
kiro_args=launch_args,
|
||
model=model,
|
||
prompt=prompt,
|
||
auto_open_conversation=auto_open_conversation,
|
||
)
|
||
|
||
|
||
def _reject_reserved_kiro_resume_args(kiro_args: tuple[str, ...]) -> None:
|
||
"""Reject Kiro-owned resume flags in passthrough args."""
|
||
reserved = {"--resume", "--resume-id", "--resume-picker"}
|
||
if any(arg == flag or arg.startswith(f"{flag}=") for arg in kiro_args for flag in reserved):
|
||
raise click.UsageError(
|
||
"Kiro resume flags are reserved for Omnigent resume handling; use "
|
||
"`omnigent kiro --resume [CONVERSATION]` instead."
|
||
)
|
||
|
||
|
||
def _build_kiro_launch_args(
|
||
*,
|
||
effort: str | None,
|
||
kiro_agent: str | None,
|
||
trust_tools: tuple[str, ...],
|
||
trust_all_tools: bool,
|
||
passthrough_args: tuple[str, ...],
|
||
) -> tuple[str, ...]:
|
||
"""Build mapped Kiro CLI args for the runner-owned terminal launch."""
|
||
args: list[str] = []
|
||
if effort:
|
||
args.extend(["--effort", effort])
|
||
if kiro_agent:
|
||
args.extend(["--agent", kiro_agent])
|
||
for tool in trust_tools:
|
||
args.extend(["--trust-tools", tool])
|
||
if trust_all_tools:
|
||
args.append("--trust-all-tools")
|
||
args.extend(passthrough_args)
|
||
return tuple(args)
|
||
|
||
|
||
@cli.command(
|
||
context_settings={
|
||
"ignore_unknown_options": True,
|
||
"allow_extra_args": True,
|
||
}
|
||
)
|
||
@click.option(
|
||
"--server",
|
||
default=None,
|
||
help=(
|
||
"Remote omnigent URL. Ensures the host daemon, asks the "
|
||
"daemon-spawned runner to launch the Goose TUI, and attaches this TTY. "
|
||
'Pass --server "" to auto-spawn a persistent local server in the '
|
||
"background and use that instead of a remote one."
|
||
),
|
||
)
|
||
@click.option(
|
||
"-r",
|
||
"--resume",
|
||
"resume",
|
||
is_flag=False,
|
||
flag_value=_RESUME_PICKER_SENTINEL,
|
||
default=None,
|
||
help=(
|
||
"Resume a prior Omnigent conversation. With a conversation id "
|
||
"(e.g. ``--resume conv_abc123``) attaches directly; with no value "
|
||
"opens an interactive picker scoped to goose-native sessions."
|
||
),
|
||
)
|
||
@click.option(
|
||
"--session",
|
||
"session_id",
|
||
metavar="SESSION_ID",
|
||
default=None,
|
||
hidden=True,
|
||
help="Deprecated alias for ``--resume <id>``; kept for one release.",
|
||
)
|
||
@click.argument("goose_args", nargs=-1, type=click.UNPROCESSED)
|
||
def goose(
|
||
server: str | None,
|
||
resume: str | None,
|
||
session_id: str | None,
|
||
goose_args: tuple[str, ...],
|
||
) -> None:
|
||
"""Launch the Goose TUI in an Omnigent terminal.
|
||
|
||
\b
|
||
Examples:
|
||
omnigent goose
|
||
omnigent goose --resume conv_abc123
|
||
omnigent goose --resume # interactive picker
|
||
"""
|
||
choice = _split_resume_value(resume)
|
||
if session_id is not None and (choice.picker or choice.conversation_id is not None):
|
||
raise click.UsageError(
|
||
"--session and --resume are mutually exclusive; "
|
||
"prefer --resume (--session is deprecated).",
|
||
)
|
||
|
||
from omnigent.goose_native import run_goose_native
|
||
|
||
cfg = _load_effective_config()
|
||
if server is None:
|
||
server = cfg.get("server")
|
||
auto_open_conversation = _resolve_auto_open_conversation_from_config(cfg)
|
||
|
||
server = _ensure_backend(server)
|
||
resolved_session_id = (
|
||
choice.conversation_id if choice.conversation_id is not None else session_id
|
||
)
|
||
|
||
run_goose_native(
|
||
server=server,
|
||
session_id=resolved_session_id,
|
||
resume_picker=choice.picker,
|
||
goose_args=goose_args,
|
||
auto_open_conversation=auto_open_conversation,
|
||
)
|
||
|
||
|
||
@cli.command(
|
||
context_settings={
|
||
"ignore_unknown_options": True,
|
||
"allow_extra_args": True,
|
||
}
|
||
)
|
||
@click.option(
|
||
"--server",
|
||
default=None,
|
||
help=(
|
||
"Remote omnigent URL. Ensures the host daemon, asks the "
|
||
"daemon-spawned runner to launch the Hermes TUI, and attaches this TTY. "
|
||
'Pass --server "" to auto-spawn a persistent local server in the '
|
||
"background and use that instead of a remote one."
|
||
),
|
||
)
|
||
@click.option(
|
||
"-r",
|
||
"--resume",
|
||
"resume",
|
||
is_flag=False,
|
||
flag_value=_RESUME_PICKER_SENTINEL,
|
||
default=None,
|
||
help=(
|
||
"Resume a prior Omnigent conversation. With a conversation id "
|
||
"(e.g. ``--resume conv_abc123``) attaches directly; with no value "
|
||
"opens an interactive picker scoped to hermes-native sessions."
|
||
),
|
||
)
|
||
@click.option(
|
||
"--session",
|
||
"session_id",
|
||
metavar="SESSION_ID",
|
||
default=None,
|
||
hidden=True,
|
||
help="Deprecated alias for ``--resume <id>``; kept for one release.",
|
||
)
|
||
@click.argument("hermes_args", nargs=-1, type=click.UNPROCESSED)
|
||
def hermes(
|
||
server: str | None,
|
||
resume: str | None,
|
||
session_id: str | None,
|
||
hermes_args: tuple[str, ...],
|
||
) -> None:
|
||
"""Launch the Hermes TUI in an Omnigent terminal.
|
||
|
||
\b
|
||
Examples:
|
||
omnigent hermes
|
||
omnigent hermes --resume conv_abc123
|
||
omnigent hermes --resume # interactive picker
|
||
"""
|
||
choice = _split_resume_value(resume)
|
||
if session_id is not None and (choice.picker or choice.conversation_id is not None):
|
||
raise click.UsageError(
|
||
"--session and --resume are mutually exclusive; "
|
||
"prefer --resume (--session is deprecated).",
|
||
)
|
||
|
||
from omnigent.hermes_native import run_hermes_native
|
||
|
||
cfg = _load_effective_config()
|
||
if server is None:
|
||
server = cfg.get("server")
|
||
auto_open_conversation = _resolve_auto_open_conversation_from_config(cfg)
|
||
|
||
server = _ensure_backend(server)
|
||
resolved_session_id = (
|
||
choice.conversation_id if choice.conversation_id is not None else session_id
|
||
)
|
||
|
||
run_hermes_native(
|
||
server=server,
|
||
session_id=resolved_session_id,
|
||
resume_picker=choice.picker,
|
||
hermes_args=hermes_args,
|
||
auto_open_conversation=auto_open_conversation,
|
||
)
|
||
|
||
|
||
@cli.command(
|
||
context_settings={
|
||
"ignore_unknown_options": True,
|
||
"allow_extra_args": True,
|
||
}
|
||
)
|
||
@click.option(
|
||
"--server",
|
||
default=None,
|
||
help=(
|
||
"Remote omnigent URL. Ensures the host daemon, binds a runner, "
|
||
"launches Antigravity (agy) in a terminal resource, and attaches "
|
||
'this TTY. Pass --server "" to auto-spawn a persistent local '
|
||
"server in the background and use that instead of a remote one."
|
||
),
|
||
)
|
||
@click.option(
|
||
"-r",
|
||
"--resume",
|
||
"resume",
|
||
is_flag=False,
|
||
flag_value=_RESUME_PICKER_SENTINEL,
|
||
default=None,
|
||
help=(
|
||
"Resume a prior Omnigent conversation. With a conversation id "
|
||
"(e.g. ``--resume conv_abc123``) attaches directly; with no value "
|
||
"opens an interactive picker scoped to antigravity-native sessions."
|
||
),
|
||
)
|
||
@click.option(
|
||
"--session",
|
||
"session_id",
|
||
metavar="SESSION_ID",
|
||
default=None,
|
||
hidden=True,
|
||
help="Deprecated alias for ``--resume <id>``; kept for one release.",
|
||
)
|
||
@click.option("--model", default=None, help="Antigravity (agy) model to use for the session.")
|
||
@click.argument("antigravity_args", nargs=-1, type=click.UNPROCESSED)
|
||
def antigravity(
|
||
server: str | None,
|
||
resume: str | None,
|
||
session_id: str | None,
|
||
model: str | None,
|
||
antigravity_args: tuple[str, ...],
|
||
) -> None:
|
||
"""Launch the Antigravity (agy) TUI in an Omnigent terminal.
|
||
|
||
\b
|
||
Examples:
|
||
omnigent antigravity
|
||
omnigent antigravity --resume conv_abc123
|
||
omnigent antigravity --resume # interactive picker
|
||
omnigent antigravity --server https://<app>.databricksapps.com
|
||
"""
|
||
# Validate option combinations BEFORE any side effects (daemon spawn,
|
||
# server discovery) -- see the same comment in the claude command.
|
||
choice = _split_resume_value(resume)
|
||
if session_id is not None and (choice.picker or choice.conversation_id is not None):
|
||
raise click.UsageError(
|
||
"--session and --resume are mutually exclusive; "
|
||
"prefer --resume (--session is deprecated).",
|
||
)
|
||
|
||
from omnigent.antigravity_native import run_antigravity_native
|
||
|
||
cfg = _load_effective_config()
|
||
if server is None:
|
||
server = cfg.get("server")
|
||
if model is None:
|
||
model = cfg.get("model")
|
||
auto_open_conversation = _resolve_auto_open_conversation_from_config(cfg)
|
||
|
||
server = _ensure_backend(server)
|
||
resolved_session_id = (
|
||
choice.conversation_id if choice.conversation_id is not None else session_id
|
||
)
|
||
|
||
# permission_mode is left None here (parity with the claude/codex/pi CLI
|
||
# launchers): the attended terminal launch lets agy's own request-review
|
||
# prompt govern each tool, and an unattended/headless launch auto-bypasses
|
||
# inside run_antigravity_native. It is plumbed through build_agy_launch so a
|
||
# future caller CAN set it, but this human CLI path exposes no permission
|
||
# flag and never needs one.
|
||
run_antigravity_native(
|
||
server=server,
|
||
session_id=resolved_session_id,
|
||
resume_picker=choice.picker,
|
||
antigravity_args=antigravity_args,
|
||
model=model,
|
||
auto_open_conversation=auto_open_conversation,
|
||
)
|
||
|
||
|
||
@cli.command(
|
||
context_settings={
|
||
"ignore_unknown_options": True,
|
||
"allow_extra_args": True,
|
||
}
|
||
)
|
||
@click.option(
|
||
"--server",
|
||
default=None,
|
||
help=(
|
||
"Remote omnigent URL. Ensures the host daemon, asks the "
|
||
"daemon-spawned runner to launch the qwen TUI, and attaches this TTY. "
|
||
'Pass --server "" to auto-spawn a persistent local server in the '
|
||
"background and use that instead of a remote one."
|
||
),
|
||
)
|
||
@click.option(
|
||
"-r",
|
||
"--resume",
|
||
"resume",
|
||
is_flag=False,
|
||
flag_value=_RESUME_PICKER_SENTINEL,
|
||
default=None,
|
||
help=(
|
||
"Resume a prior Omnigent conversation. With a conversation id "
|
||
"(e.g. ``--resume conv_abc123``) attaches directly; with no value "
|
||
"opens an interactive picker scoped to qwen-native sessions."
|
||
),
|
||
)
|
||
@click.option(
|
||
"--session",
|
||
"session_id",
|
||
metavar="SESSION_ID",
|
||
default=None,
|
||
hidden=True,
|
||
help="Deprecated alias for ``--resume <id>``; kept for one release.",
|
||
)
|
||
@click.argument("qwen_args", nargs=-1, type=click.UNPROCESSED)
|
||
def qwen(
|
||
server: str | None,
|
||
resume: str | None,
|
||
session_id: str | None,
|
||
qwen_args: tuple[str, ...],
|
||
) -> None:
|
||
"""Launch the qwen (Qwen Code) TUI in an Omnigent terminal.
|
||
|
||
\b
|
||
Examples:
|
||
omnigent qwen
|
||
omnigent qwen --resume conv_abc123
|
||
omnigent qwen --resume # interactive picker
|
||
"""
|
||
choice = _split_resume_value(resume)
|
||
if session_id is not None and (choice.picker or choice.conversation_id is not None):
|
||
raise click.UsageError(
|
||
"--session and --resume are mutually exclusive; "
|
||
"prefer --resume (--session is deprecated).",
|
||
)
|
||
|
||
from omnigent.qwen_native import run_qwen_native
|
||
|
||
cfg = _load_effective_config()
|
||
if server is None:
|
||
server = cfg.get("server")
|
||
auto_open_conversation = _resolve_auto_open_conversation_from_config(cfg)
|
||
|
||
server = _ensure_backend(server)
|
||
resolved_session_id = (
|
||
choice.conversation_id if choice.conversation_id is not None else session_id
|
||
)
|
||
|
||
run_qwen_native(
|
||
server=server,
|
||
session_id=resolved_session_id,
|
||
resume_picker=choice.picker,
|
||
qwen_args=qwen_args,
|
||
auto_open_conversation=auto_open_conversation,
|
||
)
|
||
|
||
|
||
def _run_bundled_agent(name: str, run_args: tuple[str, ...]) -> None:
|
||
"""Forward a bundled-agent subcommand to ``run`` on its packaged path.
|
||
|
||
Implements ``omnigent polly`` / ``omnigent debby``: resolves the bundled
|
||
example directory and re-dispatches through the ``run`` command's own
|
||
parser, so every ``run`` flag (``--server``, ``-p``, ``--resume``, ...)
|
||
works unchanged on the agent shorthands without duplicating ``run``'s
|
||
option declarations.
|
||
|
||
``prog_name`` is pinned to ``"omnigent run"`` so context-derived output —
|
||
usage errors and the :func:`_build_resume_parts` replay prefix — renders
|
||
as the canonical ``omnigent run <path>`` form, which stays valid when
|
||
replayed.
|
||
|
||
:param name: Bundled example directory name, e.g. ``"polly"``.
|
||
:param run_args: Unparsed pass-through CLI args for ``run``,
|
||
e.g. ``("-p", "review the last commit")``.
|
||
"""
|
||
# Polly/Debby launch with the first available credential for their
|
||
# brain's family when no specific one is configured up front (#334).
|
||
_ensure_bundled_agent_brain_credential(name)
|
||
# standalone_mode=False propagates ClickExceptions to main()'s handler
|
||
# (CLI diagnostics logging + setup hint) instead of exiting inline,
|
||
# matching the outer `cli(args=argv, standalone_mode=False)` dispatch.
|
||
run.main(
|
||
args=[_bundled_example_path(name), *run_args],
|
||
prog_name="omnigent run",
|
||
standalone_mode=False,
|
||
)
|
||
|
||
|
||
@cli.command(
|
||
context_settings={
|
||
"ignore_unknown_options": True,
|
||
"allow_extra_args": True,
|
||
}
|
||
)
|
||
@click.argument("run_args", nargs=-1, type=click.UNPROCESSED)
|
||
def polly(run_args: tuple[str, ...]) -> None:
|
||
# Param docs live in comments — Click uses the docstring for --help.
|
||
# :param run_args: Pass-through args for ``run``.
|
||
"""Launch polly, the bundled multi-agent coding orchestrator.
|
||
|
||
Shorthand for ``omnigent run`` on the packaged polly agent — the same
|
||
agent a bare ``omnigent`` launches when a Claude credential is
|
||
configured. All ``run`` options are accepted and forwarded.
|
||
|
||
\b
|
||
Examples:
|
||
omnigent polly
|
||
omnigent polly -p "review the last commit"
|
||
omnigent polly --server https://<app>.databricksapps.com
|
||
"""
|
||
_run_bundled_agent("polly", run_args)
|
||
|
||
|
||
@cli.command(
|
||
context_settings={
|
||
"ignore_unknown_options": True,
|
||
"allow_extra_args": True,
|
||
}
|
||
)
|
||
@click.argument("run_args", nargs=-1, type=click.UNPROCESSED)
|
||
def debby(run_args: tuple[str, ...]) -> None:
|
||
# Param docs live in comments — Click uses the docstring for --help.
|
||
# :param run_args: Pass-through args for ``run``.
|
||
"""Launch debby, the bundled two-headed brainstorming agent.
|
||
|
||
Shorthand for ``omnigent run`` on the packaged debby agent. Debby fans
|
||
every question out to both a Claude and a GPT sub-agent, so a Claude
|
||
and an OpenAI provider must both be configured. All ``run`` options are
|
||
accepted and forwarded.
|
||
|
||
\b
|
||
Examples:
|
||
omnigent debby
|
||
omnigent debby -p "name ideas for a CLI that runs agents"
|
||
"""
|
||
_run_bundled_agent("debby", run_args)
|
||
|
||
|
||
@cli.command(
|
||
context_settings={
|
||
"ignore_unknown_options": True,
|
||
"allow_extra_args": True,
|
||
}
|
||
)
|
||
@click.option(
|
||
"--server",
|
||
default=None,
|
||
help=(
|
||
"Remote omnigent URL. Ensures the host daemon, asks the "
|
||
"daemon-spawned runner to launch the Kimi TUI, and attaches this TTY. "
|
||
'Pass --server "" to auto-spawn a persistent local server in the '
|
||
"background and use that instead of a remote one."
|
||
),
|
||
)
|
||
@click.option(
|
||
"-r",
|
||
"--resume",
|
||
"resume",
|
||
is_flag=False,
|
||
flag_value=_RESUME_PICKER_SENTINEL,
|
||
default=None,
|
||
help=(
|
||
"Resume a prior Omnigent conversation. With a conversation id "
|
||
"(e.g. ``--resume conv_abc123``) attaches directly; with no value "
|
||
"opens an interactive picker scoped to kimi-native sessions."
|
||
),
|
||
)
|
||
@click.option(
|
||
"--session",
|
||
"session_id",
|
||
metavar="SESSION_ID",
|
||
default=None,
|
||
hidden=True,
|
||
help="Deprecated alias for ``--resume <id>``; kept for one release.",
|
||
)
|
||
@click.argument("kimi_args", nargs=-1, type=click.UNPROCESSED)
|
||
def kimi(
|
||
server: str | None,
|
||
resume: str | None,
|
||
session_id: str | None,
|
||
kimi_args: tuple[str, ...],
|
||
) -> None:
|
||
"""Launch the Kimi Code TUI in an Omnigent terminal.
|
||
|
||
Boots Moonshot AI's interactive ``kimi`` TUI
|
||
(https://github.com/MoonshotAI/Kimi-Code) in a runner-owned terminal and
|
||
attaches your TTY — the native experience, embedded in the Omnigent web
|
||
UI. No Omnigent provider config is needed: kimi authenticates against its
|
||
own backend (``kimi login`` for OAuth, or a Moonshot API key).
|
||
|
||
For the headless SDK harness (per-turn ``kimi -p`` behind the Omnigent
|
||
REPL) use ``omnigent run --harness kimi`` instead.
|
||
|
||
\b
|
||
Examples:
|
||
omnigent kimi
|
||
omnigent kimi --resume conv_abc123
|
||
omnigent kimi --resume # interactive picker
|
||
"""
|
||
choice = _split_resume_value(resume)
|
||
if session_id is not None and (choice.picker or choice.conversation_id is not None):
|
||
raise click.UsageError(
|
||
"--session and --resume are mutually exclusive; "
|
||
"prefer --resume (--session is deprecated).",
|
||
)
|
||
|
||
from omnigent.kimi_native import run_kimi_native
|
||
|
||
cfg = _load_effective_config()
|
||
if server is None:
|
||
server = cfg.get("server")
|
||
auto_open_conversation = _resolve_auto_open_conversation_from_config(cfg)
|
||
|
||
server = _ensure_backend(server)
|
||
resolved_session_id = (
|
||
choice.conversation_id if choice.conversation_id is not None else session_id
|
||
)
|
||
|
||
run_kimi_native(
|
||
server=server,
|
||
session_id=resolved_session_id,
|
||
resume_picker=choice.picker,
|
||
kimi_args=kimi_args,
|
||
auto_open_conversation=auto_open_conversation,
|
||
)
|
||
|
||
|
||
@cli.command()
|
||
@click.argument("target", required=False, metavar="[CONV_ID]")
|
||
@click.option(
|
||
"--server",
|
||
default=None,
|
||
help=(
|
||
"Remote omnigent URL. When set, the picker / lookup queries "
|
||
"this server instead of starting a local one. Required when "
|
||
"running ``omnigent resume`` without a conversation id."
|
||
),
|
||
)
|
||
def resume(
|
||
target: str | None,
|
||
server: str | None,
|
||
) -> None:
|
||
# Click uses the docstring as --help text — keep param docs in
|
||
# comments so they don't leak into CLI output.
|
||
#
|
||
# :param target: Optional Omnigent conversation id, e.g.
|
||
# ``"conv_abc123"``. None falls through to the picker.
|
||
# :param server: Remote Omnigent server URL (optional in id mode;
|
||
# required in picker mode).
|
||
"""Resume an Omnigent conversation, auto-dispatching by runtime.
|
||
|
||
\b
|
||
With CONV_ID: looks up the conversation and dispatches to the
|
||
matching wrapper. claude-native sessions land in
|
||
``omnigent claude``; everything else surfaces a clear hint to
|
||
use ``omnigent run --resume <id> <agent.yaml>``.
|
||
|
||
\b
|
||
Without CONV_ID: opens a cross-agent picker over your prior
|
||
conversations (requires ``--server``). Dispatch follows from
|
||
the row you select.
|
||
|
||
\b
|
||
Examples:
|
||
omnigent resume conv_abc123
|
||
omnigent resume conv_abc123 --server https://<app>.databricksapps.com
|
||
omnigent resume --server https://<app>.databricksapps.com
|
||
"""
|
||
from omnigent.resume_dispatch import run_resume
|
||
|
||
run_resume(
|
||
target=target,
|
||
server=_resolve_server_url(server) if server else server,
|
||
)
|
||
|
||
|
||
@cli.group("session", invoke_without_command=True)
|
||
@click.pass_context
|
||
def session(ctx: click.Context) -> None:
|
||
"""Manage Omnigent sessions.
|
||
|
||
\b
|
||
Examples:
|
||
omnigent session export --id conv_abc123
|
||
omnigent session export --id conv_abc123 --output transcript.jsonl
|
||
omnigent session export --id conv_abc123 --server https://myserver.com
|
||
"""
|
||
if ctx.invoked_subcommand is None:
|
||
click.echo(ctx.get_help())
|
||
|
||
|
||
@session.command("export")
|
||
@click.option(
|
||
"--id",
|
||
"session_id",
|
||
required=True,
|
||
metavar="SESSION_ID",
|
||
help="Session ID to export, e.g. conv_abc123.",
|
||
)
|
||
@click.option(
|
||
"--output",
|
||
"-o",
|
||
"output",
|
||
default=None,
|
||
metavar="FILE",
|
||
help="Output file path. Defaults to <SESSION_ID>.jsonl in the current directory.",
|
||
)
|
||
@click.option(
|
||
"--server",
|
||
default=None,
|
||
help=(
|
||
"Omnigent server URL. "
|
||
"Defaults to the configured server, or a local server already running."
|
||
),
|
||
)
|
||
def session_export(session_id: str, output: str | None, server: str | None) -> None:
|
||
"""Export a session transcript to a portable JSONL file.
|
||
|
||
Each line of the output is a JSON object. The first line carries
|
||
the session metadata (``"record_type": "session_meta"``); every
|
||
subsequent line is one conversation item
|
||
(``"record_type": "item"``). The file preserves full turn order
|
||
and can be re-imported with a future ``omnigent session import``.
|
||
|
||
\b
|
||
Examples:
|
||
omnigent session export --id conv_abc123
|
||
omnigent session export --id conv_abc123 --output my_session.jsonl
|
||
omnigent session export --id conv_abc123 --server https://myserver.com
|
||
"""
|
||
import httpx
|
||
|
||
from omnigent.chat import _remote_headers
|
||
|
||
cfg = _load_effective_config()
|
||
base_url = _resolve_attach_server(server, cfg.get("server"))
|
||
if base_url is None:
|
||
startup = ensure_local_omnigent_server()
|
||
base_url = startup.url
|
||
|
||
base_url = base_url.rstrip("/")
|
||
out_path = Path(output) if output else Path(f"{session_id}.jsonl")
|
||
|
||
with httpx.Client(
|
||
base_url=base_url, headers=_remote_headers(server_url=base_url), timeout=30.0
|
||
) as client:
|
||
# Fetch session metadata (items fetched separately via pagination).
|
||
resp = client.get(
|
||
f"/v1/sessions/{session_id}",
|
||
params={"include_items": "false", "include_liveness": "false"},
|
||
)
|
||
if resp.status_code == 404:
|
||
raise click.ClickException(f"Session {session_id!r} not found.")
|
||
resp.raise_for_status()
|
||
session_data = resp.json()
|
||
|
||
n_items = 0
|
||
with out_path.open("w", encoding="utf-8") as fh:
|
||
# First line: session metadata.
|
||
meta_record = {"record_type": "session_meta", **session_data}
|
||
fh.write(json.dumps(meta_record) + "\n")
|
||
|
||
# Remaining lines: items in ascending order, paginated.
|
||
after: str | None = None
|
||
while True:
|
||
params: dict[str, str | int] = {"limit": 500, "order": "asc"}
|
||
if after:
|
||
params["after"] = after
|
||
items_resp = client.get(f"/v1/sessions/{session_id}/items", params=params)
|
||
items_resp.raise_for_status()
|
||
page = items_resp.json()
|
||
for item in page["data"]:
|
||
item_record = {"record_type": "item", **item}
|
||
fh.write(json.dumps(item_record) + "\n")
|
||
n_items += 1
|
||
if not page.get("has_more"):
|
||
break
|
||
after = page.get("last_id")
|
||
|
||
click.echo(f"Exported {n_items} item(s) from {session_id} to {out_path}")
|
||
|
||
|
||
# Shared option help for ``run`` and the harness commands. These are the same
|
||
# flags the legacy argparse CLI exposed — keeping them on the unified
|
||
# click CLI so users don't regress when a YAML declares no executor
|
||
# block (e.g. ``examples/hello_world.yaml``) or when they want to
|
||
# choose model/harness without editing the agent file. See
|
||
# ``omnigent.chat.run_chat`` for how local-agent options get baked
|
||
# into a materialized copy of the spec before the server starts.
|
||
_HARNESS_CHOICES_HELP = (
|
||
"'claude' (alias for 'claude-sdk'), 'claude-sdk', 'codex', "
|
||
"'cursor', 'kimi', "
|
||
"'openai-agents', 'open-responses', 'pi', 'antigravity', 'qwen', 'goose', or 'copilot'"
|
||
)
|
||
_HARNESS_HELP = f"Harness to use for a local agent: {_HARNESS_CHOICES_HELP}."
|
||
_RUN_HARNESS_HELP = (
|
||
f"Harness to use: {_HARNESS_CHOICES_HELP}. Without AGENT, launches that harness directly."
|
||
)
|
||
_MODEL_HELP = "Model to use for the agent."
|
||
_PROMPT_HELP = "Send this as the first message when the REPL starts."
|
||
_SYSTEM_PROMPT_HELP = "Instructions to use for the agent."
|
||
_RESUME_HELP = (
|
||
"Resume a prior conversation. With no value, opens an interactive "
|
||
"picker; with a conversation id (e.g. --resume conv_abc123), attaches "
|
||
"directly to that conversation."
|
||
)
|
||
_CONTINUE_HELP = "Continue the most recent conversation for this agent."
|
||
_NO_SESSION_HELP = "Use a fresh temporary local session store for this run."
|
||
|
||
_FORK_HELP = "Fork an existing session by id and open the REPL on the fork."
|
||
_LOG_HELP = "Write a JSON dump of the conversation to ~/.omnigent/logs/ on exit."
|
||
|
||
|
||
_DEFAULT_HARNESS_PROMPTS = {
|
||
"claude-sdk": (
|
||
"You are Claude Code, running through Omnigent. "
|
||
"Help the user with software engineering tasks."
|
||
),
|
||
"codex": (
|
||
"You are Codex, running through Omnigent. Help the user with software engineering tasks."
|
||
),
|
||
"cursor": (
|
||
"You are Cursor, running through Omnigent. Help the user with software engineering tasks."
|
||
),
|
||
"kimi": (
|
||
"You are Kimi Code, running through Omnigent. "
|
||
"Help the user with software engineering tasks."
|
||
),
|
||
"qwen": (
|
||
"You are Qwen Code, running through Omnigent. "
|
||
"Help the user with software engineering tasks."
|
||
),
|
||
"goose": (
|
||
"You are Goose, running through Omnigent. Help the user with software engineering tasks."
|
||
),
|
||
}
|
||
_DEFAULT_HARNESS_PROMPT = "You are a helpful coding agent running through Omnigent."
|
||
|
||
# Harnesses whose auto-generated launcher YAML should include an
|
||
# ``os_env`` block. This triggers the workflow's ``ToolManager``
|
||
# to inject ``sys_os_*`` tools into the request so file/shell
|
||
# operations route through the Omnigent dispatch path (runner
|
||
# visibility, timeouts, error recovery) instead of the harness's
|
||
# internal built-in tools.
|
||
_OS_ENV_HARNESSES: frozenset[str] = frozenset(
|
||
{"claude-sdk", "codex", "pi", "qwen", "goose", "kimi"}
|
||
)
|
||
|
||
|
||
def _validate_harness(harness: str) -> None:
|
||
"""
|
||
Fail fast when *harness* is not a supported Omnigent harness.
|
||
|
||
:param harness: Harness id from ``--harness``, e.g.
|
||
``"claude-sdk"``.
|
||
:raises click.ClickException: If *harness* is unsupported.
|
||
"""
|
||
from omnigent.spec._omnigent_compat import OMNIGENT_HARNESSES
|
||
|
||
if canonicalize_harness(harness) in OMNIGENT_HARNESSES:
|
||
return
|
||
allowed = ", ".join(sorted(OMNIGENT_HARNESSES))
|
||
raise click.ClickException(f"Unsupported harness {harness!r}. Expected one of: {allowed}.")
|
||
|
||
|
||
def _default_harness_prompt(harness: str) -> str:
|
||
"""
|
||
Return the lightweight generated-agent instructions for *harness*.
|
||
|
||
:param harness: Supported harness id.
|
||
:returns: Prompt text for the generated Omnigent YAML.
|
||
"""
|
||
return _DEFAULT_HARNESS_PROMPTS.get(harness, _DEFAULT_HARNESS_PROMPT)
|
||
|
||
|
||
def _materialize_harness_launcher_file(
|
||
*,
|
||
harness: str,
|
||
model: str | None,
|
||
system_prompt: str | None,
|
||
) -> Path:
|
||
"""
|
||
Create a temporary standalone Omnigent YAML for no-AGENT ``run``.
|
||
|
||
The generated file uses the single-file Omnigent YAML shape
|
||
(``name`` / ``prompt`` / ``executor``), not native AP
|
||
``config.yaml``. Passing this file to ``run_chat`` exercises the
|
||
same compat adapter as ``omnigent run examples/foo.yaml``.
|
||
|
||
Harnesses listed in :data:`_OS_ENV_HARNESSES` get an ``os_env``
|
||
block so the workflow injects ``sys_os_*`` tools into the
|
||
request — routing file/shell operations through the Omnigent
|
||
dispatch path rather than the harness's internal built-ins.
|
||
|
||
:param harness: Supported harness id to launch, e.g.
|
||
``"claude-sdk"``.
|
||
:param model: Optional model value to bake into ``executor``.
|
||
:param system_prompt: Optional instructions text to use as the
|
||
YAML's top-level ``prompt``.
|
||
:returns: Path to the generated ``*.yaml`` file.
|
||
:raises click.ClickException: If *harness* is unsupported.
|
||
"""
|
||
_validate_harness(harness)
|
||
canonical = canonicalize_harness(harness) or harness
|
||
# An acp:<slug> harness id carries a colon: it canonicalizes to the base
|
||
# `acp` harness, but the slug selects a user-configured ACP agent resolved
|
||
# at spawn and must be preserved. So the effective harness id written to
|
||
# executor.harness is the FULL acp:<slug> (keep the slug), or the canonical
|
||
# id for every other harness (so aliases still resolve, e.g. kimi ->
|
||
# kimi-code). The agent NAME and temp filename must be path-safe /
|
||
# [a-zA-Z0-9_-]+, so the colon is sanitized there only.
|
||
effective_harness = harness if canonical == "acp" and ":" in harness else canonical
|
||
# Name preserves the user's input (matching the pre-acp behavior, e.g.
|
||
# --harness claude -> name "claude"), sanitized for the colon so acp:<slug>
|
||
# yields a valid [a-zA-Z0-9_-]+ name. Filename uses the canonical/effective
|
||
# id (also colon-sanitized) as before.
|
||
display_name = harness.replace(":", "-")
|
||
|
||
tmpdir = Path(tempfile.mkdtemp(prefix="omnigent-harness-launcher-"))
|
||
yaml_path = tmpdir / f"{effective_harness.replace(':', '-')}.yaml"
|
||
|
||
executor: dict[str, str] = {"harness": effective_harness}
|
||
if model is not None:
|
||
executor["model"] = model
|
||
|
||
raw = {
|
||
"name": display_name,
|
||
"prompt": system_prompt or _default_harness_prompt(canonical),
|
||
"executor": executor,
|
||
}
|
||
if canonical in _OS_ENV_HARNESSES:
|
||
raw["os_env"] = {"type": "caller_process", "sandbox": {"type": "none"}}
|
||
yaml_path.write_text(yaml.safe_dump(raw, default_flow_style=False))
|
||
return yaml_path
|
||
|
||
|
||
def _missing_run_agent_message() -> str:
|
||
"""Return the no-AGENT ``run`` guidance shown on missing input."""
|
||
return (
|
||
"Provide an AGENT path, pass --server to connect to a server, "
|
||
"or pass --harness to launch a built-in "
|
||
"harness directly:\n"
|
||
" omnigent run examples/hello_world.yaml\n"
|
||
" omnigent run --server http://localhost:6767\n"
|
||
" omnigent run --harness claude-sdk\n"
|
||
" omnigent run --harness codex"
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _ResumeChoice:
|
||
"""
|
||
Outcome of parsing the click ``--resume`` option value.
|
||
|
||
Named fields rather than a tuple so a future shape change (e.g. a
|
||
third resume mode) doesn't become a positional break at every
|
||
call site.
|
||
"""
|
||
|
||
picker: bool
|
||
conversation_id: str | None
|
||
|
||
|
||
def _split_resume_value(resume: str | None) -> _ResumeChoice:
|
||
"""
|
||
Translate the click ``--resume`` option value into the internal
|
||
``resume_picker`` / ``resume_conversation_id`` shape.
|
||
|
||
``--resume`` is wired with ``is_flag=False`` + ``flag_value``, so
|
||
click hands us one of three values:
|
||
|
||
- ``None`` — option absent. No resume requested.
|
||
- :data:`_RESUME_PICKER_SENTINEL` — ``--resume`` passed without a
|
||
value. User wants the interactive picker.
|
||
- any other string — ``--resume <id>``. User wants to attach to
|
||
that specific conversation id.
|
||
|
||
The downstream dispatcher / ``run_chat`` boundary still takes the
|
||
two-field shape (the picker mode and the conv-id mode end up in
|
||
different code paths inside ``_resolve_resume_target``); the
|
||
split lives here so the click layer is the only place that knows
|
||
about the consolidation.
|
||
"""
|
||
if resume is None:
|
||
return _ResumeChoice(picker=False, conversation_id=None)
|
||
if resume == _RESUME_PICKER_SENTINEL:
|
||
return _ResumeChoice(picker=True, conversation_id=None)
|
||
return _ResumeChoice(picker=False, conversation_id=resume)
|
||
|
||
|
||
# Params that are one-shot or replaced on resume — excluded from the
|
||
# resume command hint. Everything else Click parsed is preserved
|
||
# automatically, so new flags don't need any resume-hint bookkeeping.
|
||
_RESUME_SKIP_PARAMS: frozenset[str] = frozenset(
|
||
{
|
||
"prompt",
|
||
"resume",
|
||
"resume_latest",
|
||
"fork_session_id",
|
||
# ephemeral is session-scoped infrastructure flag, not
|
||
# meaningful across invocations.
|
||
"ephemeral",
|
||
}
|
||
)
|
||
|
||
|
||
def _build_resume_parts() -> list[str]:
|
||
"""Build the flag-preserving prefix for the resume command from Click's
|
||
parsed context.
|
||
|
||
Iterates the active Click context's parameters and reconstructs
|
||
every flag/argument whose value differs from its default, skipping
|
||
one-shot params (``-p``, ``--fork``, ``-c``, ``--resume``, etc.).
|
||
The caller appends ``--resume <conversation_id>`` and joins with
|
||
:func:`shlex.join`.
|
||
|
||
Must be called while a Click context is active (i.e. inside a
|
||
Click command handler or a function it calls synchronously).
|
||
|
||
:returns: Argument list prefix, e.g.
|
||
``["omnigent", "run", "agent.yaml", "--server",
|
||
"https://example.com"]``.
|
||
"""
|
||
ctx = click.get_current_context()
|
||
parts: list[str] = ctx.command_path.split()
|
||
|
||
for param in ctx.command.params:
|
||
if param.name is None or param.name in _RESUME_SKIP_PARAMS:
|
||
continue
|
||
value = ctx.params.get(param.name)
|
||
if value is None or value == param.default:
|
||
continue
|
||
|
||
if isinstance(param, click.Argument):
|
||
parts.append(str(value))
|
||
elif isinstance(param, click.Option):
|
||
# Prefer the long-form flag (e.g. --harness over -h).
|
||
flag = max(param.opts, key=len)
|
||
if param.is_flag:
|
||
parts.append(flag)
|
||
else:
|
||
parts.append(flag)
|
||
parts.append(str(value))
|
||
|
||
return parts
|
||
|
||
|
||
def _dispatch_native_terminal_harness(
|
||
*,
|
||
harness: str,
|
||
server: str | None,
|
||
model: str | None,
|
||
prompt: str | None,
|
||
system_prompt: str | None,
|
||
tools: str | None,
|
||
log: bool,
|
||
debug_events: bool,
|
||
resume_conversation_id: str | None,
|
||
resume_picker: bool,
|
||
resume_latest: bool,
|
||
fork_session_id: str | None,
|
||
ephemeral: bool,
|
||
auto_open_conversation: bool,
|
||
) -> bool:
|
||
"""
|
||
Launch a ``*-native`` terminal harness via its TUI wrapper directly.
|
||
|
||
``run --harness cursor-native`` (and the claude/codex/pi equivalents)
|
||
must NOT go through the materialized-launcher REPL: that drives an
|
||
Omnigent turn per message — which persists its own user item — *while*
|
||
the harness forwarder mirrors the same message back from the TUI's
|
||
transcript, recording every user message twice. These harnesses are
|
||
terminal-mirror sessions whose turns originate in the TUI, so dispatch
|
||
straight to the native wrapper (the same code ``omnigent cursor`` /
|
||
``omnigent claude`` / etc. run), keeping the TUI the single source of
|
||
turns. A top-level ``--model`` is forwarded as a passthrough CLI flag.
|
||
|
||
``--continue`` is honored (not rejected): it resolves to this harness's
|
||
most-recent conversation and hands that off to the wrapper, matching the
|
||
pre-dispatch launcher behavior so it is not a silent resume regression.
|
||
|
||
:param harness: The requested ``--harness`` value (canonical or alias).
|
||
:returns: ``True`` when *harness* is a native terminal harness and was
|
||
dispatched here; ``False`` when it is not one (caller continues).
|
||
"""
|
||
from omnigent.native_coding_agents import native_coding_agent_for_harness
|
||
|
||
native_agent = native_coding_agent_for_harness(harness)
|
||
if native_agent is None:
|
||
return False
|
||
|
||
# The native TUI wrappers attach to a tmux pane and own their own turn
|
||
# loop, so REPL-only options have no analog there. Reject them loudly
|
||
# rather than silently dropping them, and point at the dedicated
|
||
# subcommand. (``--continue``/``--resume <id>``/``--resume`` picker ARE
|
||
# supported below — they map onto the wrapper's session selection.)
|
||
unsupported = [
|
||
flag
|
||
for flag, active in (
|
||
("-p/--prompt", prompt is not None),
|
||
("--system-prompt", system_prompt is not None),
|
||
("--tools", tools is not None),
|
||
("--log", log),
|
||
("--debug-events", debug_events),
|
||
("--fork", fork_session_id is not None),
|
||
("--no-session", ephemeral),
|
||
)
|
||
if active
|
||
]
|
||
if unsupported:
|
||
# These are REPL-only options with no analog in the TUI — and the
|
||
# dedicated subcommand doesn't accept them either (it would treat them
|
||
# as passthrough args), so tell the user to drop them rather than
|
||
# redirect. ``--model`` and session selection (--resume/--continue) ARE
|
||
# honored here.
|
||
raise click.ClickException(
|
||
f"`run --harness {harness}` launches the {native_agent.display_name} TUI directly; "
|
||
f"the REPL-only option(s) {', '.join(unsupported)} have no effect there — remove them."
|
||
)
|
||
|
||
server = _ensure_backend(server)
|
||
passthrough = ("--model", model) if model else ()
|
||
|
||
# Resolve --continue to a concrete conversation id (the wrappers take a
|
||
# session id / picker, not a "latest" flag). Precedence matches the REPL:
|
||
# an explicit id wins, then the picker, then --continue.
|
||
session_id = resume_conversation_id
|
||
if session_id is None and not resume_picker and resume_latest:
|
||
from omnigent.chat import _remote_headers, _resolve_latest_conversation_id
|
||
|
||
session_id = _resolve_latest_conversation_id(
|
||
base_url=server,
|
||
agent_name=native_agent.agent_name,
|
||
headers=_remote_headers(server_url=server),
|
||
)
|
||
# The user explicitly asked to continue; if there's nothing to continue,
|
||
# fail loud rather than silently starting fresh (matches the REPL's
|
||
# _resolve_resume_target behavior).
|
||
if session_id is None:
|
||
raise click.ClickException(
|
||
f"No prior conversation for agent {native_agent.agent_name!r}."
|
||
)
|
||
|
||
common = {
|
||
"server": server,
|
||
"session_id": session_id,
|
||
"resume_picker": resume_picker,
|
||
"auto_open_conversation": auto_open_conversation,
|
||
}
|
||
if native_agent.key == "claude":
|
||
from omnigent.claude_native import run_claude_native
|
||
|
||
run_claude_native(claude_args=passthrough, **common)
|
||
elif native_agent.key == "codex":
|
||
from omnigent.codex_native import run_codex_native
|
||
|
||
# Codex takes its model as a first-class arg, not a passthrough flag.
|
||
run_codex_native(codex_args=(), model=model, **common)
|
||
elif native_agent.key == "pi":
|
||
from omnigent.pi_native import run_pi_native
|
||
|
||
run_pi_native(pi_args=passthrough, **common)
|
||
elif native_agent.key == "cursor":
|
||
from omnigent.cursor_native import run_cursor_native
|
||
|
||
run_cursor_native(cursor_args=passthrough, **common)
|
||
elif native_agent.key == "opencode":
|
||
from omnigent.opencode_native import run_opencode_native
|
||
|
||
# OpenCode pins its model on the wrapper spec (like Codex), so it takes
|
||
# ``model`` first-class rather than via a ``--model`` passthrough arg.
|
||
run_opencode_native(opencode_args=(), model=model, **common)
|
||
elif native_agent.key == "kimi":
|
||
from omnigent.kimi_native import run_kimi_native
|
||
|
||
run_kimi_native(kimi_args=passthrough, **common)
|
||
else: # pragma: no cover - new native agent added without a dispatch arm
|
||
raise click.ClickException(f"No native terminal launcher wired for harness {harness!r}.")
|
||
return True
|
||
|
||
|
||
def _reject_agent_with_native_terminal_harness(harness: str) -> None:
|
||
"""
|
||
Reject ``run AGENT --harness <x>-native``: native harnesses own their TUI.
|
||
|
||
A ``*-native`` harness mirrors an external CLI's own TUI; the agent spec's
|
||
prompt/tools are never consulted, and driving it through the REPL would
|
||
double-record every message (Omnigent turn + forwarder mirror). So an
|
||
explicit AGENT path combined with a native terminal harness has no coherent
|
||
meaning — fail loud and point at the dedicated subcommand.
|
||
|
||
:param harness: The requested ``--harness`` value (canonical or alias).
|
||
:raises click.ClickException: When *harness* is a native terminal harness.
|
||
"""
|
||
from omnigent.native_coding_agents import native_coding_agent_for_harness
|
||
|
||
native_agent = native_coding_agent_for_harness(harness)
|
||
if native_agent is None:
|
||
return
|
||
raise click.ClickException(
|
||
f"`--harness {harness}` launches the {native_agent.display_name} TUI and "
|
||
f"ignores an AGENT spec; drop the AGENT path and run "
|
||
f"`omnigent {native_agent.terminal_name}` (or `run --harness {harness}`)."
|
||
)
|
||
|
||
|
||
def _dispatch_run(
|
||
*,
|
||
target: str | None,
|
||
tools: str | None,
|
||
harness: str | None,
|
||
model: str | None,
|
||
prompt: str | None,
|
||
system_prompt: str | None,
|
||
server: str | None = None,
|
||
resume_picker: bool = False,
|
||
resume_latest: bool = False,
|
||
resume_conversation_id: str | None = None,
|
||
fork_session_id: str | None = None,
|
||
ephemeral: bool = False,
|
||
log: bool = False,
|
||
debug_events: bool = False,
|
||
resume_parts: list[str] | None = None,
|
||
auto_open_conversation: bool = False,
|
||
server_from_cli: bool = False,
|
||
) -> None:
|
||
"""
|
||
Route ``omnigent run`` to the right impl.
|
||
|
||
The click path always drives the Omnigent server-backed REPL. With
|
||
``--server <url>``, use that server URL instead of starting a
|
||
local server. (``omnigent attach`` is a separate attach-only
|
||
client and does NOT route through here.)
|
||
|
||
:param target: Agent YAML/directory path, or ``None`` for
|
||
``run --harness ...`` launcher mode / ``--server`` direct-server
|
||
mode.
|
||
:param tools: ``--tools`` client-side tool set name.
|
||
:param harness: ``--harness`` value.
|
||
:param model: ``--model`` value.
|
||
:param prompt: ``-p`` / ``--prompt`` value.
|
||
:param system_prompt: ``--system-prompt`` value.
|
||
:param server: Server URL from ``--server`` or config. With a local
|
||
target, this is the Omnigent server used for upload/session setup; with
|
||
no target and explicit ``--server``, this is the direct server.
|
||
:param resume_picker: True when ``--resume`` / ``-r`` is set with
|
||
no value (interactive picker).
|
||
:param resume_latest: True when ``--continue`` / ``-c`` is set.
|
||
:param resume_conversation_id: Explicit conversation id from
|
||
``--resume <id>``.
|
||
:param fork_session_id: When set, fork this session and open the
|
||
REPL on the fork. Mutually exclusive with ``--resume`` and
|
||
``--continue``.
|
||
:param ephemeral: True when ``--no-session`` is set.
|
||
:param log: True when ``--log`` is set.
|
||
:param debug_events: True when ``--debug-events`` is set.
|
||
Enables the SSE event tape overlay, JSONL event logging,
|
||
and pipeline counters in the toolbar.
|
||
:param resume_parts: Pre-built argument list prefix for the
|
||
resume command shown on exit, e.g.
|
||
``["omnigent", "run", "agent.yaml", "--harness", "codex"]``.
|
||
``None`` when called outside the Click command path.
|
||
:param auto_open_conversation: When ``True``, open the
|
||
browser conversation URL when the session id becomes known.
|
||
:param server_from_cli: ``True`` when ``--server`` was explicitly
|
||
provided on the command line. Used to distinguish direct-server
|
||
mode from a configured default server.
|
||
"""
|
||
if target is not None and _is_server_url(target):
|
||
raise click.ClickException(
|
||
"Server URLs are no longer accepted as the AGENT argument. "
|
||
f"Use `omnigent run --server {target}` instead."
|
||
)
|
||
|
||
if target is None:
|
||
if server_from_cli and server is not None and harness is None:
|
||
# Normalize like every other entry point: expand a bare workspace
|
||
# URL to its /api/2.0/omnigent mount and strip any ?o= query. Else
|
||
# a direct ``--server`` request hits the root and bounces to /login.
|
||
base_url = _resolve_server_url(server)
|
||
# Direct ``--server`` (no AGENT) has no local runner to bind, so an
|
||
# interactive resume-by-id is an ATTACH: route it through the
|
||
# `attach` pair (`_require_live_conversation` + `run_attach`), not
|
||
# the picker+create path that crashed at runner-bind ("requires a
|
||
# registered runner id"). Only the *pure interactive*
|
||
# shape reroutes — a one-shot ``-p`` or any local-agent-only flag
|
||
# (--model/--system-prompt/--log/--no-session) falls through to the
|
||
# existing remote-URL path below, which one-shots or fails loud as
|
||
# before instead of silently no-op'ing here. Picker/`--continue`
|
||
# have no id to attach to and likewise stay on that path.
|
||
# Pure interactive shape = no one-shot prompt and no local-agent-only
|
||
# override; the ``resume_conversation_id is not None`` check stays in
|
||
# the ``if`` so the type narrows for the calls below.
|
||
is_interactive_shape = (
|
||
prompt is None
|
||
and not resume_latest
|
||
and not resume_picker
|
||
and fork_session_id is None
|
||
and not log
|
||
and not ephemeral
|
||
and model is None
|
||
and system_prompt is None
|
||
)
|
||
if resume_conversation_id is not None and is_interactive_shape:
|
||
from omnigent.chat import _redirect_native_resume_if_needed, run_attach
|
||
|
||
if _redirect_native_resume_if_needed(
|
||
base_url=base_url,
|
||
conversation_id=resume_conversation_id,
|
||
auto_open_conversation=auto_open_conversation,
|
||
):
|
||
return
|
||
|
||
_require_live_conversation(
|
||
base_url=base_url,
|
||
conversation_id=resume_conversation_id,
|
||
)
|
||
run_attach(
|
||
base_url=base_url,
|
||
conversation_id=resume_conversation_id,
|
||
client_tools=tools,
|
||
debug_events=debug_events,
|
||
auto_open_conversation=auto_open_conversation,
|
||
# Keep the run-style parts so the exit "Resume:" hint
|
||
# reproduces the (now-working) command the user ran.
|
||
resume_parts=resume_parts,
|
||
)
|
||
return
|
||
|
||
from omnigent.chat import run_chat
|
||
|
||
run_chat(
|
||
target=base_url,
|
||
client_tools=tools,
|
||
server_url=None,
|
||
harness=harness,
|
||
model=model,
|
||
prompt=prompt,
|
||
system_prompt=system_prompt,
|
||
ephemeral=ephemeral,
|
||
resume_conversation_id=resume_conversation_id,
|
||
resume_latest=resume_latest,
|
||
resume_picker=resume_picker,
|
||
fork_session_id=fork_session_id,
|
||
log=log,
|
||
debug_events=debug_events,
|
||
resume_parts=resume_parts,
|
||
auto_open_conversation=auto_open_conversation,
|
||
)
|
||
return
|
||
if harness is None:
|
||
raise click.ClickException(_missing_run_agent_message())
|
||
# ``*-native`` terminal harnesses launch their own TUI wrapper instead of
|
||
# the materialized-launcher REPL — the REPL would double-record every
|
||
# user message (Omnigent turn + forwarder mirror). Returns False for
|
||
# non-native harnesses, which fall through to the launcher below.
|
||
if _dispatch_native_terminal_harness(
|
||
harness=harness,
|
||
server=server,
|
||
model=model,
|
||
prompt=prompt,
|
||
system_prompt=system_prompt,
|
||
tools=tools,
|
||
log=log,
|
||
debug_events=debug_events,
|
||
resume_conversation_id=resume_conversation_id,
|
||
resume_picker=resume_picker,
|
||
resume_latest=resume_latest,
|
||
fork_session_id=fork_session_id,
|
||
ephemeral=ephemeral,
|
||
auto_open_conversation=auto_open_conversation,
|
||
):
|
||
return
|
||
if ephemeral:
|
||
raise click.ClickException(
|
||
"--no-session requires an AGENT path; no-AGENT harness launch "
|
||
"already uses a generated temporary agent spec."
|
||
)
|
||
target = str(
|
||
_materialize_harness_launcher_file(
|
||
harness=harness,
|
||
model=model,
|
||
system_prompt=system_prompt,
|
||
)
|
||
)
|
||
harness = None
|
||
model = None
|
||
system_prompt = None
|
||
elif harness is not None:
|
||
_validate_harness(harness)
|
||
# A ``*-native`` harness IS its own TUI agent — pairing it with an AGENT
|
||
# spec is meaningless, and routing it through the REPL would double-record
|
||
# every message (Omnigent turn + forwarder mirror, same as the no-AGENT
|
||
# path above). Reject rather than silently launch the broken surface.
|
||
_reject_agent_with_native_terminal_harness(harness)
|
||
|
||
if server is not None:
|
||
if _is_server_url(target):
|
||
raise click.ClickException(
|
||
"--server is for binding a LOCAL agent YAML to a remote "
|
||
"server. Pass a YAML path as the target (got a URL)."
|
||
)
|
||
|
||
if fork_session_id is not None:
|
||
if resume_conversation_id or resume_latest or resume_picker:
|
||
raise click.ClickException(
|
||
"--fork is mutually exclusive with --resume and --continue."
|
||
)
|
||
if prompt is not None:
|
||
raise click.ClickException(
|
||
"--fork requires interactive REPL mode; remove -p/--prompt."
|
||
)
|
||
|
||
harness = canonicalize_harness(harness)
|
||
if prompt is not None:
|
||
if resume_conversation_id is not None or resume_latest or resume_picker:
|
||
from omnigent.chat import run_chat
|
||
|
||
run_chat(
|
||
target=target,
|
||
client_tools=tools,
|
||
server_url=server,
|
||
harness=harness,
|
||
model=model,
|
||
prompt=prompt,
|
||
system_prompt=system_prompt,
|
||
ephemeral=ephemeral,
|
||
resume_conversation_id=resume_conversation_id,
|
||
resume_latest=resume_latest,
|
||
resume_picker=resume_picker,
|
||
debug_events=debug_events,
|
||
auto_open_conversation=auto_open_conversation,
|
||
)
|
||
return
|
||
if log:
|
||
raise click.ClickException(
|
||
"--log is only supported in interactive REPL mode on this CLI path; "
|
||
"remove -p/--prompt to run headlessly."
|
||
)
|
||
# Headless ``-p`` runs against the daemon-backed server too (the
|
||
# host daemon connects to ``--server`` or starts a local server),
|
||
# so it stays consistent with interactive mode. ``run_chat`` runs
|
||
# one-shot and exits when ``initial_message`` is set. The only
|
||
# exception is ``--no-session``: it keeps the legacy in-process
|
||
# ephemeral path via ``run_prompt`` (no daemon, no persistence).
|
||
if not ephemeral:
|
||
from omnigent.chat import run_chat
|
||
|
||
run_chat(
|
||
target=target,
|
||
client_tools=tools,
|
||
server_url=server,
|
||
harness=harness,
|
||
model=model,
|
||
prompt=prompt,
|
||
system_prompt=system_prompt,
|
||
ephemeral=False,
|
||
debug_events=debug_events,
|
||
auto_open_conversation=auto_open_conversation,
|
||
)
|
||
return
|
||
|
||
from omnigent.chat import run_prompt
|
||
|
||
run_prompt(
|
||
target=target,
|
||
client_tools=tools,
|
||
harness=harness,
|
||
model=model,
|
||
prompt=prompt,
|
||
system_prompt=system_prompt,
|
||
ephemeral=ephemeral,
|
||
)
|
||
return
|
||
|
||
from omnigent.chat import run_chat
|
||
|
||
run_chat(
|
||
target=target,
|
||
client_tools=tools,
|
||
server_url=server,
|
||
harness=harness,
|
||
model=model,
|
||
prompt=None,
|
||
system_prompt=system_prompt,
|
||
ephemeral=ephemeral,
|
||
resume_conversation_id=resume_conversation_id,
|
||
resume_latest=resume_latest,
|
||
resume_picker=resume_picker,
|
||
fork_session_id=fork_session_id,
|
||
log=log,
|
||
debug_events=debug_events,
|
||
resume_parts=resume_parts,
|
||
auto_open_conversation=auto_open_conversation,
|
||
)
|
||
|
||
|
||
def _resolve_attach_server(server: str | None, configured_server: str | None) -> str | None:
|
||
"""
|
||
Resolve the Omnigent server URL ``attach`` should join.
|
||
|
||
Resolution order: an explicit ``--server`` value, then the configured
|
||
``server`` default, then a local Omnigent server already running in the
|
||
background. ``attach`` never starts a server, so this returns ``None``
|
||
when none of those is available and the caller fails loud.
|
||
|
||
:param server: Explicit ``--server`` value, e.g.
|
||
``"https://example.databricksapps.com"``, or ``None``.
|
||
:param configured_server: The ``server`` default from config (the
|
||
``server`` key of the effective merged config), or ``None``.
|
||
:returns: Normalized base URL without a trailing slash, or ``None``.
|
||
"""
|
||
chosen = server if server is not None else configured_server
|
||
if chosen:
|
||
return _resolve_server_url(chosen)
|
||
local = local_server_url_if_healthy()
|
||
return local.rstrip("/") if local else None
|
||
|
||
|
||
def _require_live_conversation(
|
||
*,
|
||
base_url: str,
|
||
conversation_id: str,
|
||
) -> None:
|
||
"""
|
||
Fail loud unless *conversation_id* is reachable on *base_url*.
|
||
|
||
``attach`` is an attach-only client; if the session is not live there
|
||
is nothing to join, so we surface a clear error rather than letting the
|
||
REPL connect to a phantom conversation. Issues a single
|
||
``GET /v1/sessions/{id}`` and raises on a transport failure or any
|
||
non-200 status.
|
||
|
||
:param base_url: Omnigent server base URL, e.g. ``"http://127.0.0.1:6767"``.
|
||
:param conversation_id: Conversation id to attach to, e.g.
|
||
``"conv_abc123"``.
|
||
:raises click.ClickException: When the server is unreachable or the
|
||
conversation does not exist.
|
||
"""
|
||
result = _host_http_json(
|
||
base_url=base_url,
|
||
method="GET",
|
||
path=f"/v1/sessions/{conversation_id}",
|
||
)
|
||
# ``_host_http_json`` reports transport failures as status 0 (never
|
||
# raises), so the server-down and missing-session cases both land here.
|
||
if result.status_code == 0:
|
||
raise click.ClickException(
|
||
f"Couldn't reach a server at {base_url}: {_host_error_text(result.body)}. "
|
||
"`attach` never starts a server — check the URL, or start one with "
|
||
"`omnigent run`."
|
||
)
|
||
if result.status_code != 200:
|
||
raise click.ClickException(
|
||
f"No live session '{conversation_id}' on {base_url} "
|
||
f"(server returned {result.status_code}). Run `omnigent host status` "
|
||
"to list live sessions, or `omnigent run <agent.yaml>` to start one."
|
||
)
|
||
|
||
|
||
@cli.command()
|
||
@click.argument("conversation", required=False, metavar="[CONVERSATION_ID]")
|
||
@click.option(
|
||
"--server",
|
||
default=None,
|
||
help=(
|
||
"AP server hosting the session. Defaults to the configured server, "
|
||
"or a local server already running in the background."
|
||
),
|
||
)
|
||
@click.option(
|
||
"--tools",
|
||
default=None,
|
||
help="Client-side tool set name (e.g. 'coding') for shell access.",
|
||
)
|
||
@click.option(
|
||
"--debug-events",
|
||
"debug_events",
|
||
is_flag=True,
|
||
default=False,
|
||
help=(
|
||
"Enable the SSE-to-UI debug pipeline: Ctrl+E event tape "
|
||
"overlay, JSONL event log (~/.omnigent/debug/), and "
|
||
"pipeline stage counters in the toolbar."
|
||
),
|
||
)
|
||
def attach(
|
||
conversation: str | None,
|
||
server: str | None,
|
||
tools: str | None,
|
||
debug_events: bool,
|
||
) -> None:
|
||
"""Attach the REPL to a LIVE session — never starts anything.
|
||
|
||
``attach`` is a thin client: it joins an already-running conversation
|
||
on a server and streams its I/O. It never spawns a server, runner, or
|
||
harness, applies no model/harness defaults, and errors loudly when
|
||
there is nothing live to attach to. To START a session use
|
||
``omnigent run``; to reopen/restart a stored one use
|
||
``omnigent resume``.
|
||
|
||
\b
|
||
Examples:
|
||
omnigent attach conv_abc123
|
||
omnigent attach conv_abc123 --server https://<app>.databricksapps.com
|
||
"""
|
||
cfg = _load_effective_config()
|
||
base_url = _resolve_attach_server(server, cfg.get("server"))
|
||
if base_url is None:
|
||
raise click.ClickException(
|
||
"No server to attach to. `attach` joins a LIVE session on a running "
|
||
"server — start one with `omnigent run`, or point at one with "
|
||
"`--server <url>`."
|
||
)
|
||
if conversation is None:
|
||
raise click.ClickException(
|
||
"Nothing to attach to: `attach` joins a LIVE session by id. "
|
||
f"Run `omnigent host status` to list sessions on {base_url}, or "
|
||
"`omnigent run <agent.yaml>` to start a new one."
|
||
)
|
||
_require_live_conversation(base_url=base_url, conversation_id=conversation)
|
||
auto_open_conversation = _resolve_auto_open_conversation_from_config(cfg)
|
||
from omnigent.chat import run_attach
|
||
|
||
# Attach is a pure client: it joins the live session and dispatches turns to
|
||
# the runner the host already bound (like the web UI co-drive), never
|
||
# spawning a server/runner/harness. ``run_attach`` fails loud if the host
|
||
# is offline (no online runner to dispatch to).
|
||
run_attach(
|
||
base_url=base_url,
|
||
conversation_id=conversation,
|
||
client_tools=tools,
|
||
debug_events=debug_events,
|
||
auto_open_conversation=auto_open_conversation,
|
||
resume_parts=["cli", "attach", conversation, "--server", base_url],
|
||
)
|
||
|
||
|
||
# `run` absorbs the legacy ``omnigent run`` subcommand. With an AGENT
|
||
# argument it opens the interactive REPL on a freshly started session;
|
||
# without AGENT it can launch a built-in harness directly via ``--harness``.
|
||
# Both paths route through the same Omnigent server+REPL dispatcher.
|
||
@cli.command()
|
||
@click.argument("target", required=False, metavar="[AGENT]")
|
||
@click.option(
|
||
"--tools",
|
||
default=None,
|
||
help="Client-side tool set name (e.g. 'coding') for shell access.",
|
||
)
|
||
@click.option("--harness", default=None, help=_RUN_HARNESS_HELP)
|
||
@click.option("--model", default=None, help=_MODEL_HELP)
|
||
@click.option("-p", "--prompt", default=None, help=_PROMPT_HELP)
|
||
@click.option("--system-prompt", "system_prompt", default=None, help=_SYSTEM_PROMPT_HELP)
|
||
@click.option(
|
||
"-r",
|
||
"--resume",
|
||
"resume",
|
||
is_flag=False,
|
||
flag_value=_RESUME_PICKER_SENTINEL,
|
||
default=None,
|
||
help=_RESUME_HELP,
|
||
)
|
||
@click.option(
|
||
"-c", "--continue", "resume_latest", is_flag=True, default=False, help=_CONTINUE_HELP
|
||
)
|
||
@click.option("--fork", "fork_session_id", default=None, help=_FORK_HELP)
|
||
@click.option("--no-session", "ephemeral", is_flag=True, default=False, help=_NO_SESSION_HELP)
|
||
@click.option("--log/--no-log", "log", default=False, help=_LOG_HELP)
|
||
@click.option(
|
||
"--server",
|
||
default=None,
|
||
help=(
|
||
"Remote omnigent URL. Uploads the local YAML as an ephemeral "
|
||
"agent, spawns a LOCAL runner that tunnels to this server (so "
|
||
"terminals/MCPs run on your laptop), and connects the REPL to it. "
|
||
'Pass --server "" to auto-spawn a persistent local server in the '
|
||
"background and target that instead of a remote one."
|
||
),
|
||
)
|
||
@click.option(
|
||
"--debug-events",
|
||
"debug_events",
|
||
is_flag=True,
|
||
default=False,
|
||
help=(
|
||
"Enable the SSE-to-UI debug pipeline: Ctrl+E event tape "
|
||
"overlay, JSONL event log (~/.omnigent/debug/), and "
|
||
"pipeline stage counters in the toolbar."
|
||
),
|
||
)
|
||
@click.option(
|
||
"--host",
|
||
"register_host",
|
||
is_flag=True,
|
||
default=False,
|
||
help=(
|
||
"Register this machine as a host with the remote server "
|
||
"(inline equivalent of `omnigent host`). Requires --server."
|
||
),
|
||
)
|
||
def run(
|
||
target: str | None,
|
||
tools: str | None,
|
||
harness: str | None,
|
||
model: str | None,
|
||
prompt: str | None,
|
||
system_prompt: str | None,
|
||
resume: str | None,
|
||
resume_latest: bool,
|
||
fork_session_id: str | None,
|
||
ephemeral: bool,
|
||
log: bool,
|
||
server: str | None,
|
||
debug_events: bool,
|
||
register_host: bool,
|
||
) -> None:
|
||
"""Start a session with an Omnigent agent.
|
||
|
||
AGENT may be an agent YAML file or an agent directory. Without AGENT,
|
||
pass ``--server`` to connect directly to a server, or pass
|
||
``--harness`` to launch a built-in harness directly.
|
||
|
||
Default: omnigent server+REPL architecture (spawns a local
|
||
server, REPL connects as an HTTP client). With ``--server <url>`` and
|
||
no AGENT, connect directly to that server; with AGENT, use local
|
||
runner + remote server topology (RUNNER.md §6 Flow 1) - laptop hosts
|
||
runner/harnesses, server hosts state.
|
||
|
||
\b
|
||
Examples:
|
||
omnigent run --harness claude-sdk
|
||
omnigent run --harness codex -p "review the last commit"
|
||
omnigent run examples/hello_world.yaml
|
||
omnigent run examples/hello_world.yaml --harness codex --model gpt-5.4-mini
|
||
omnigent run --server http://localhost:6767
|
||
omnigent run examples/databricks_coding_agent.yaml --server https://<app>.databricksapps.com
|
||
"""
|
||
# Apply config defaults for any value the user did not pass explicitly.
|
||
# Explicit CLI args always take precedence; project-local config overrides
|
||
# global config, which provides user-level defaults.
|
||
server_source = click.get_current_context().get_parameter_source("server")
|
||
server_from_cli = server_source is not None and server_source.name == "COMMANDLINE"
|
||
harness_source = click.get_current_context().get_parameter_source("harness")
|
||
harness_from_cli = harness_source is not None and harness_source.name == "COMMANDLINE"
|
||
direct_server_cli = (
|
||
target is None and server_from_cli and server is not None and not harness_from_cli
|
||
)
|
||
|
||
_global_cfg = _load_effective_config()
|
||
if target is None and not direct_server_cli:
|
||
# Harness-aware default-agent resolution (this branch) under main's
|
||
# direct-`--server` guard: skip the configured default_agent when the
|
||
# invocation is a bare `--server` (no AGENT, no --harness), else pick
|
||
# it — but fall back to a built-in launcher when an explicit --harness
|
||
# doesn't match the default agent's harness.
|
||
target = _resolve_default_agent_target(_global_cfg.get("default_agent"), harness)
|
||
if server is None:
|
||
server = _global_cfg.get("server")
|
||
if model is None and not direct_server_cli:
|
||
model = _global_cfg.get("model")
|
||
if harness is None and not direct_server_cli:
|
||
harness = _global_cfg.get("harness")
|
||
|
||
# First-run smart defaults: a bare `run` with no AGENT, no --harness, and no
|
||
# explicit persisted default → derive a harness from the *current* creds
|
||
# (Claude→polly, else Codex, else Pi); or drop into `configure harnesses`
|
||
# when nothing is set up. The derived pick is NOT persisted, so it tracks
|
||
# the credentials — adding Claude later promotes a Codex-only user to polly.
|
||
if target is None and harness is None and not direct_server_cli:
|
||
plan = _resolve_first_run_plan()
|
||
if plan is None:
|
||
return # nothing configured even after offering configure — exit cleanly
|
||
harness = plan.harness
|
||
target = plan.agent # polly path for Claude; None (bare harness) for codex/pi
|
||
|
||
# Interactive ``omnigent run`` opens the live conversation in the
|
||
# browser by default so users discover the web UI once the server is up
|
||
# (the accounts-mode magic-redeem auto-open used to surface this, but
|
||
# accounts is no longer the default auth). An explicit
|
||
# ``auto_open_conversation`` config value (true/false) always wins, so
|
||
# users who opted out stay opted out. Headless ``-p`` one-shots stay
|
||
# quiet unless the user explicitly opted in.
|
||
auto_open_setting = _resolve_auto_open_conversation_setting(_global_cfg)
|
||
auto_open_conversation = auto_open_setting if auto_open_setting is not None else prompt is None
|
||
|
||
# NOTE: the host daemon + Omnigent server are ensured inside ``run_chat``'s
|
||
# non-URL branch (a URL ``target`` connects directly). ``--host`` is now
|
||
# redundant (the daemon is always ensured) and kept only as a no-op.
|
||
del register_host
|
||
|
||
choice = _split_resume_value(resume)
|
||
# Capture resume-safe CLI parts before dispatch mutates target,
|
||
# harness, or model for no-AGENT launcher mode.
|
||
resume_parts = _build_resume_parts()
|
||
_dispatch_run(
|
||
target=target,
|
||
tools=tools,
|
||
harness=harness,
|
||
model=model,
|
||
prompt=prompt,
|
||
system_prompt=system_prompt,
|
||
server=server,
|
||
resume_picker=choice.picker,
|
||
resume_latest=resume_latest,
|
||
resume_conversation_id=choice.conversation_id,
|
||
fork_session_id=fork_session_id,
|
||
ephemeral=ephemeral,
|
||
log=log,
|
||
debug_events=debug_events,
|
||
resume_parts=resume_parts,
|
||
auto_open_conversation=auto_open_conversation,
|
||
server_from_cli=server_from_cli,
|
||
)
|
||
|
||
|
||
class _HostGroup(click.Group):
|
||
"""
|
||
``host`` group that accepts a server URL as a positional argument.
|
||
|
||
``omnigent host <url>`` is shorthand for ``omnigent host
|
||
--server <url>`` when ``<url>`` is URL-like or the empty local-mode
|
||
marker. A leading positional token that matches a registered
|
||
management subcommand (``status``, ``stop``, ``stop-session``)
|
||
still dispatches to that subcommand, and other unknown tokens fall
|
||
through to Click's normal unknown-command error.
|
||
"""
|
||
|
||
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
|
||
"""
|
||
Redirect a leading URL-like positional into ``--server``.
|
||
|
||
``omnigent host <url>`` is shorthand for ``omnigent host --server
|
||
<url>``. We detect a leading URL-like positional with a throwaway
|
||
option parse and, when present, rewrite the argument list to inject
|
||
``--server <url>`` *before* Click parses it -- so Click sees a normal
|
||
option and never treats the URL as a would-be subcommand.
|
||
|
||
This deliberately avoids Click's internal ``protected_args`` (made a
|
||
read-only property in click 8.2 and slated for removal in click 9),
|
||
so the shorthand keeps working across click versions. A leading token
|
||
that is a registered subcommand, or not URL-like, is left untouched
|
||
for Click's normal dispatch / unknown-command error.
|
||
|
||
:param ctx: Click context for the ``host`` group.
|
||
:param args: Raw argument tokens for the group.
|
||
:returns: Remaining args after the group consumes its own.
|
||
"""
|
||
return super().parse_args(ctx, self._rewrite_positional_server(ctx, list(args)))
|
||
|
||
def _rewrite_positional_server(self, ctx: click.Context, args: list[str]) -> list[str]:
|
||
"""
|
||
Rewrite a leading URL-like positional into an explicit ``--server``.
|
||
|
||
Runs a throwaway parse of the group's own options to find the first
|
||
positional token. When that token is URL-like (and not a registered
|
||
subcommand), removes it from *args* and prepends ``--server <token>``;
|
||
otherwise returns *args* unchanged so Click dispatches the subcommand
|
||
or raises its own unknown-command error. Raises when the positional
|
||
URL is combined with an explicit ``--server`` or with extra
|
||
positionals.
|
||
|
||
:param ctx: Click context for the ``host`` group.
|
||
:param args: Raw argument tokens for the group.
|
||
:returns: Possibly-rewritten argument tokens.
|
||
"""
|
||
# Resilient parsing (shell completion) must keep default behavior so
|
||
# subcommand names still complete.
|
||
if ctx.resilient_parsing or not args:
|
||
return args
|
||
try:
|
||
parser = self.make_parser(ctx)
|
||
# A click.Group defaults to allow_interspersed_args=False, which would
|
||
# treat an option *after* the positional URL (e.g.
|
||
# `host <url> --non-interactive`) as an extra positional. Enable
|
||
# interspersed parsing so trailing options are classified as options.
|
||
parser.allow_interspersed_args = True
|
||
opts, positionals, _ = parser.parse_args(list(args))
|
||
except click.UsageError:
|
||
# Malformed options: let the real parse surface the error.
|
||
return args
|
||
if (
|
||
not positionals
|
||
or positionals[0] in self.commands
|
||
or not self._token_is_positional_server(positionals[0])
|
||
):
|
||
return args
|
||
url = positionals[0]
|
||
if opts.get("server") is not None:
|
||
raise click.UsageError(
|
||
"Pass the server URL either positionally or via --server, not both."
|
||
)
|
||
if positionals[1:]:
|
||
raise click.UsageError(f"Unexpected extra argument(s): {' '.join(positionals[1:])}")
|
||
# remove() drops the first token equal to `url`. Safe because the only
|
||
# value-taking group option (--server) triggers the conflict error above,
|
||
# so the URL can't be some other option's value.
|
||
remaining = list(args)
|
||
remaining.remove(url)
|
||
return ["--server", url, *remaining]
|
||
|
||
def _token_is_positional_server(self, token: str) -> bool:
|
||
"""
|
||
Return whether a token may be used as positional ``host`` server.
|
||
|
||
The shorthand intentionally accepts only HTTP(S) server URLs and
|
||
the empty string local-mode marker. Plain words such as
|
||
``"sessions"`` are more likely command typos, so Click should
|
||
report them as unknown subcommands instead of treating them as
|
||
remote server addresses.
|
||
|
||
:param token: Leading positional token, e.g.
|
||
``"https://example.databricksapps.com"`` or ``""``.
|
||
:returns: ``True`` if the token should bind to ``--server``.
|
||
"""
|
||
return token == "" or _is_server_url(token)
|
||
|
||
|
||
def _prompt_stop_local_server() -> None:
|
||
"""Ask whether to also stop the detached local Omnigent server after exit.
|
||
|
||
The local-mode host daemon spawns a detached, persistent local AP
|
||
server (:func:`ensure_local_omnigent_server`) that survives the daemon's exit
|
||
so sessions and the Web UI stay reachable across ``host`` / ``run``.
|
||
Users expect Ctrl-C to stop "everything", so when a healthy local server
|
||
is still running we prompt to stop it too. Declining — or a
|
||
non-interactive / aborted prompt (EOF, a second Ctrl-C) — leaves it
|
||
running. No-op when no healthy local server is found (never spawned, or
|
||
already stopped).
|
||
|
||
:returns: None.
|
||
"""
|
||
url = local_server_url_if_healthy()
|
||
if url is None:
|
||
return
|
||
try:
|
||
stop = click.confirm(
|
||
f"\nThe local server at {url} is still running so your sessions and "
|
||
"the Web UI stay reachable across `host`/`run`.\nStop it too?",
|
||
default=False,
|
||
)
|
||
except click.Abort:
|
||
# Non-interactive stdin (EOF) or a second Ctrl-C: leave it running
|
||
# rather than hang. ``click.confirm`` maps both to ``Abort``.
|
||
click.echo()
|
||
stop = False
|
||
if stop:
|
||
stop_local_omnigent_server()
|
||
click.echo(f"Stopped the local server ({url}).")
|
||
else:
|
||
click.echo(f"Left the local server running at {url}.")
|
||
|
||
|
||
@cli.group("host", cls=_HostGroup, invoke_without_command=True)
|
||
@click.option("--server", default=None, help="Remote omnigent server URL.")
|
||
@click.option(
|
||
"--non-interactive",
|
||
"non_interactive",
|
||
is_flag=True,
|
||
default=False,
|
||
help=(
|
||
"Never prompt for sign-in. When the server requires auth and you "
|
||
"are not logged in, fail with the `omnigent login` hint instead of "
|
||
"launching the browser login flow. Use this in scripts and CI."
|
||
),
|
||
)
|
||
@click.pass_context
|
||
def host(ctx: click.Context, server: str | None, non_interactive: bool) -> None:
|
||
"""
|
||
Register this machine as a host with a server.
|
||
|
||
\b
|
||
Examples:
|
||
omnigent host https://omnigent-app.databricksapps.com
|
||
omnigent host --server https://omnigent-app.databricksapps.com
|
||
omnigent host "" # spawn + connect to a local server
|
||
|
||
The server URL may be given positionally (``omnigent host
|
||
<url>``) or via ``--server <url>``. A leading ``status``, ``stop``,
|
||
or ``stop-session`` token still runs that management subcommand.
|
||
|
||
When the target server is Databricks-fronted and you are not signed
|
||
in, ``host`` runs the same flow ``omnigent login`` would before
|
||
connecting (an interactive browser flow). Pass ``--non-interactive``
|
||
to keep the old scripted behavior: fail with the login command to run
|
||
instead of prompting.
|
||
|
||
:param ctx: Click invocation context. ``ctx.invoked_subcommand`` is
|
||
set when a management subcommand such as ``"status"`` is running.
|
||
:param server: Remote Omnigent server URL, e.g.
|
||
``"https://example.databricksapps.com"``. ``None`` falls back
|
||
to config; empty string selects local mode.
|
||
:param non_interactive: When ``True``, never launch the browser login
|
||
for an un-authed remote server — fail with the ``omnigent login``
|
||
hint instead.
|
||
"""
|
||
ctx.ensure_object(dict)
|
||
ctx.obj["server"] = server
|
||
if ctx.invoked_subcommand is not None:
|
||
return
|
||
cfg = _load_effective_config()
|
||
if server is None:
|
||
server = cfg.get("server")
|
||
if server:
|
||
server = _resolve_server_url(server)
|
||
# Remote mode is decided here, before the local-mode branch reassigns
|
||
# ``server`` to the spawned loopback URL — only a remote target needs
|
||
# the sign-in pre-flight.
|
||
remote_mode = bool(server)
|
||
|
||
from omnigent.host.connect import run_host_process
|
||
|
||
# ``host`` IS the daemon (foreground). With no server URL, start (or
|
||
# reuse) the local Omnigent server here and connect to it; otherwise connect to
|
||
# the given remote/local URL. Unlike the background commands, we do not
|
||
# spawn a second daemon via ``_ensure_host_daemon``.
|
||
target = _normalize_daemon_target(server)
|
||
# Only true when THIS invocation started the local server (vs reusing one
|
||
# already started by `omnigent server` or a prior host/run daemon) —
|
||
# gates the Ctrl-C stop-server prompt so we never offer to stop a server
|
||
# we didn't bring up.
|
||
spawned_local_server = False
|
||
if not server:
|
||
startup = ensure_local_omnigent_server()
|
||
server = startup.url
|
||
spawned_local_server = startup.spawned
|
||
record = _foreground_daemon_record(
|
||
target=target,
|
||
server_url=server,
|
||
host_id=_load_or_create_host_id(),
|
||
)
|
||
previous = _claim_foreground_daemon_record(record)
|
||
# Only offer to stop the local server after a clean stop (Ctrl-C / normal
|
||
# exit). A connection failure (SystemExit) leaves this False so we don't
|
||
# prompt over an error.
|
||
stopped_cleanly = False
|
||
try:
|
||
# Sign in first when the remote server is Databricks-fronted and we
|
||
# hold no usable credentials — otherwise the tunnel upgrade is
|
||
# redirected to a login page and the host dies with an opaque
|
||
# "redirected to a login page" error after several retries. On a TTY
|
||
# this runs the browser login and continues; ``--non-interactive``
|
||
# (or a headless invocation) fails loud with the command to run.
|
||
if remote_mode:
|
||
_ensure_databricks_server_auth(server, non_interactive=non_interactive)
|
||
run_host_process(server_url=server)
|
||
stopped_cleanly = True
|
||
except KeyboardInterrupt:
|
||
# Ctrl-C is the normal way to stop the foreground daemon — swallow it
|
||
# so we can prompt below instead of exiting with an "Aborted!" trace.
|
||
stopped_cleanly = True
|
||
finally:
|
||
_restore_replaced_daemon_record(record, previous)
|
||
# Offer to stop the local server only when WE spawned it this run.
|
||
# Not in --server mode (someone else's server), and not when we reused
|
||
# a server started by `omnigent server` or another daemon — killing
|
||
# that would surprise the user who brought it up independently. Users
|
||
# expect Ctrl-C to stop "everything" they started, so the server we
|
||
# spawned is fair game.
|
||
if stopped_cleanly and spawned_local_server:
|
||
_prompt_stop_local_server()
|
||
|
||
|
||
def _host_group_option(ctx: click.Context, key: str) -> str | None:
|
||
"""
|
||
Read a group-level ``omnigent host`` option for a subcommand.
|
||
|
||
:param ctx: Click context passed to a host subcommand.
|
||
:param key: Group option key, e.g. ``"server"``.
|
||
:returns: The string option value, or ``None``.
|
||
"""
|
||
obj = ctx.obj if isinstance(ctx.obj, dict) else {}
|
||
value = obj.get(key)
|
||
return value if isinstance(value, str) else None
|
||
|
||
|
||
def _resolve_host_server(server: str | None) -> str | None:
|
||
"""
|
||
Resolve a host-management server from CLI or config.
|
||
|
||
:param server: Explicit ``--server`` value, e.g.
|
||
``"https://example.databricksapps.com"``. ``None`` falls back
|
||
to config; empty string selects local mode.
|
||
:returns: Normalized server URL, or ``None`` for local mode.
|
||
"""
|
||
if server is None:
|
||
configured = _load_effective_config().get("server")
|
||
server = str(configured) if configured else None
|
||
return _resolve_server_url(server) if server else None
|
||
|
||
|
||
def _daemon_base_url(record: _HostDaemonRecord) -> str | None:
|
||
"""
|
||
Resolve the Omnigent server URL for a daemon record.
|
||
|
||
:param record: Daemon registry record to inspect.
|
||
:returns: Omnigent server URL, e.g. ``"http://127.0.0.1:8123"``, or
|
||
``None`` when a local daemon's server cannot be discovered.
|
||
"""
|
||
if record.mode == "local":
|
||
if record.resolved_server_url:
|
||
return record.resolved_server_url.rstrip("/")
|
||
local_url = local_server_url_if_healthy()
|
||
return local_url.rstrip("/") if local_url else None
|
||
return (record.server_url or record.target).rstrip("/")
|
||
|
||
|
||
def _selected_daemon_records(
|
||
*,
|
||
server: str | None,
|
||
all_targets: bool,
|
||
default_all: bool,
|
||
) -> list[_HostDaemonRecord]:
|
||
"""
|
||
Select daemon records for a host-management command.
|
||
|
||
:param server: Explicit ``--server`` value, e.g.
|
||
``"https://example.databricksapps.com"``. ``None`` may mean
|
||
all targets or config/local depending on ``default_all``.
|
||
:param all_targets: Whether ``--all`` was passed.
|
||
:param default_all: Whether no selector should mean all records.
|
||
:returns: Matching daemon records.
|
||
:raises click.ClickException: If ``--server`` and ``--all`` conflict.
|
||
"""
|
||
if all_targets and server is not None:
|
||
raise click.ClickException("Use either --server or --all, not both.")
|
||
if all_targets or (server is None and default_all):
|
||
return _list_daemon_records()
|
||
target = _normalize_daemon_target(_resolve_host_server(server))
|
||
record = _find_daemon_record(target)
|
||
return [] if record is None else [record]
|
||
|
||
|
||
def _host_http_json(
|
||
*,
|
||
base_url: str,
|
||
method: str,
|
||
path: str,
|
||
params: dict[str, str | int] | None = None,
|
||
json_body: _HostJsonObject | None = None,
|
||
timeout_s: float = 10.0,
|
||
) -> _HostHttpResult:
|
||
"""
|
||
Send one management request to an Omnigent server.
|
||
|
||
:param base_url: Omnigent server base URL, e.g.
|
||
``"https://example.databricksapps.com"``.
|
||
:param method: HTTP method, e.g. ``"GET"`` or ``"POST"``.
|
||
:param path: Request path beginning with ``/``, e.g.
|
||
``"/v1/hosts/host_abc"``.
|
||
:param params: Optional query parameters, e.g. ``{"limit": 1000}``.
|
||
:param json_body: Optional JSON body, e.g.
|
||
``{"type": "stop_session", "data": {}}``.
|
||
:param timeout_s: Request timeout in seconds, e.g. ``2.0`` for a
|
||
quick liveness probe. Defaults to ``10.0`` for management calls.
|
||
:returns: Decoded HTTP result.
|
||
"""
|
||
import httpx
|
||
|
||
from omnigent.chat import _remote_headers
|
||
|
||
try:
|
||
with httpx.Client(
|
||
base_url=base_url,
|
||
headers=_remote_headers(server_url=base_url),
|
||
timeout=timeout_s,
|
||
) as client:
|
||
resp = client.request(method, path, params=params, json=json_body)
|
||
except (httpx.HTTPError, OSError) as exc:
|
||
return _HostHttpResult(
|
||
status_code=0,
|
||
body=f"{type(exc).__name__}: {exc}",
|
||
)
|
||
body: _HostJsonObject | str
|
||
try:
|
||
decoded = resp.json()
|
||
except ValueError:
|
||
body = resp.text
|
||
else:
|
||
body = cast(_HostJsonObject, decoded) if isinstance(decoded, dict) else str(decoded)
|
||
return _HostHttpResult(status_code=resp.status_code, body=body)
|
||
|
||
|
||
def _host_error_text(body: _HostJsonObject | str) -> str:
|
||
"""
|
||
Extract a concise error string from an Omnigent response body.
|
||
|
||
:param body: Response body decoded by :func:`_host_http_json`.
|
||
:returns: Human-readable error text.
|
||
"""
|
||
if isinstance(body, str):
|
||
return body[:400]
|
||
detail = body.get("detail")
|
||
if isinstance(detail, str):
|
||
return detail
|
||
error = body.get("error")
|
||
if isinstance(error, dict):
|
||
message = error.get("message")
|
||
if isinstance(message, str):
|
||
return message
|
||
return json.dumps(body)[:400]
|
||
|
||
|
||
def _daemon_session_request_params(
|
||
*,
|
||
connected_only: bool,
|
||
after: str | None,
|
||
) -> dict[str, str | int]:
|
||
"""
|
||
Build query parameters for one sessions page.
|
||
|
||
:param connected_only: When ``True``, ask the server for connected
|
||
sessions only.
|
||
:param after: Optional cursor from the prior page, e.g.
|
||
``"conv_abc123"``.
|
||
:returns: Query parameters for ``GET /v1/sessions``.
|
||
"""
|
||
params: dict[str, str | int] = {
|
||
"limit": 1000,
|
||
"include_archived": "true",
|
||
}
|
||
if connected_only:
|
||
params["connected"] = "true"
|
||
if after is not None:
|
||
params["after"] = after
|
||
return params
|
||
|
||
|
||
def _decode_sessions_page(
|
||
result: _HostHttpResult,
|
||
) -> _SessionsPageResult:
|
||
"""
|
||
Decode one ``GET /v1/sessions`` response page.
|
||
|
||
:param result: HTTP result returned by :func:`_host_http_json`.
|
||
:returns: Decoded page result. ``error`` is ``None`` on success.
|
||
"""
|
||
if result.status_code == 0:
|
||
return _SessionsPageResult(
|
||
sessions=[],
|
||
last_id=None,
|
||
has_more=False,
|
||
error=f"session list failed: {_host_error_text(result.body)}",
|
||
)
|
||
if result.status_code >= 400:
|
||
return _SessionsPageResult(
|
||
sessions=[],
|
||
last_id=None,
|
||
has_more=False,
|
||
error=(f"session list failed ({result.status_code}): {_host_error_text(result.body)}"),
|
||
)
|
||
if not isinstance(result.body, dict):
|
||
return _SessionsPageResult(
|
||
sessions=[],
|
||
last_id=None,
|
||
has_more=False,
|
||
error="session list returned a non-object response",
|
||
)
|
||
data = result.body.get("data")
|
||
if not isinstance(data, list):
|
||
return _SessionsPageResult(
|
||
sessions=[],
|
||
last_id=None,
|
||
has_more=False,
|
||
error="session list returned a malformed data field",
|
||
)
|
||
rows = [s for s in data if isinstance(s, dict)]
|
||
last_id = result.body.get("last_id")
|
||
has_more = result.body.get("has_more")
|
||
return _SessionsPageResult(
|
||
sessions=rows,
|
||
last_id=last_id if isinstance(last_id, str) and last_id else None,
|
||
has_more=has_more if isinstance(has_more, bool) else False,
|
||
error=None,
|
||
)
|
||
|
||
|
||
def _fetch_session_pages(
|
||
*,
|
||
base_url: str,
|
||
connected_only: bool,
|
||
) -> _SessionPagesResult:
|
||
"""
|
||
Fetch every available session page from a server.
|
||
|
||
:param base_url: Omnigent server base URL, e.g.
|
||
``"https://example.databricksapps.com"``.
|
||
:param connected_only: When ``True``, ask the server for connected
|
||
sessions only.
|
||
:returns: Accumulated sessions result. ``error`` is ``None`` on success.
|
||
"""
|
||
after: str | None = None
|
||
sessions: list[_HostSessionRow] = []
|
||
while True:
|
||
page_result = _host_http_json(
|
||
base_url=base_url,
|
||
method="GET",
|
||
path="/v1/sessions",
|
||
params=_daemon_session_request_params(
|
||
connected_only=connected_only,
|
||
after=after,
|
||
),
|
||
)
|
||
page = _decode_sessions_page(page_result)
|
||
if page.error is not None:
|
||
return _SessionPagesResult(sessions=[], error=page.error)
|
||
sessions.extend(page.sessions)
|
||
if not page.has_more or page.last_id is None:
|
||
return _SessionPagesResult(sessions=sessions, error=None)
|
||
after = page.last_id
|
||
|
||
|
||
def _sessions_for_daemon(
|
||
record: _HostDaemonRecord,
|
||
*,
|
||
connected_only: bool = False,
|
||
) -> _DaemonSessionsResult:
|
||
"""
|
||
Fetch sessions owned by a daemon's host id.
|
||
|
||
:param record: Daemon record whose sessions should be listed.
|
||
:param connected_only: When ``True``, ask the server for connected
|
||
sessions only.
|
||
:returns: Sessions result. ``error`` is ``None`` on success.
|
||
"""
|
||
base_url = _daemon_base_url(record)
|
||
if base_url is None:
|
||
return _DaemonSessionsResult(
|
||
base_url=None,
|
||
sessions=[],
|
||
error="local Omnigent server is not reachable",
|
||
)
|
||
host_id = record.host_id or _load_existing_host_id()
|
||
if not host_id:
|
||
return _DaemonSessionsResult(
|
||
base_url=base_url,
|
||
sessions=[],
|
||
error="host id is not available in local config",
|
||
)
|
||
pages = _fetch_session_pages(
|
||
base_url=base_url,
|
||
connected_only=connected_only,
|
||
)
|
||
if pages.error is not None:
|
||
return _DaemonSessionsResult(base_url=base_url, sessions=[], error=pages.error)
|
||
owned = [s for s in pages.sessions if s.get("host_id") == host_id]
|
||
return _DaemonSessionsResult(base_url=base_url, sessions=owned, error=None)
|
||
|
||
|
||
def _runner_online_map(
|
||
*,
|
||
base_url: str,
|
||
sessions: list[_HostSessionRow],
|
||
) -> dict[str, bool | None]:
|
||
"""
|
||
Resolve live runner connectivity for sessions.
|
||
|
||
:param base_url: Omnigent server base URL, e.g.
|
||
``"https://example.databricksapps.com"``.
|
||
:param sessions: Session rows containing ``runner_id`` values.
|
||
:returns: Map of ``runner_id`` to ``True`` / ``False``. ``None``
|
||
means the runner status could not be resolved.
|
||
"""
|
||
from omnigent.claude_native_bridge import url_component
|
||
|
||
runner_ids = sorted(
|
||
{
|
||
runner_id
|
||
for session in sessions
|
||
if isinstance((runner_id := session.get("runner_id")), str) and runner_id
|
||
}
|
||
)
|
||
statuses: dict[str, bool | None] = {}
|
||
for runner_id in runner_ids:
|
||
result = _host_http_json(
|
||
base_url=base_url,
|
||
method="GET",
|
||
path=f"/v1/runners/{url_component(runner_id)}/status",
|
||
)
|
||
if result.status_code == 200 and isinstance(result.body, dict):
|
||
online = result.body.get("online")
|
||
statuses[runner_id] = online if isinstance(online, bool) else None
|
||
else:
|
||
statuses[runner_id] = None
|
||
return statuses
|
||
|
||
|
||
def _annotate_sessions_with_runner_online(
|
||
*,
|
||
base_url: str,
|
||
sessions: list[_HostSessionRow],
|
||
) -> list[_HostSessionRow]:
|
||
"""
|
||
Add ``runner_online`` to session rows.
|
||
|
||
:param base_url: Omnigent server base URL, e.g.
|
||
``"https://example.databricksapps.com"``.
|
||
:param sessions: Session rows returned by ``GET /v1/sessions``.
|
||
:returns: Copies of the session rows with ``runner_online`` added.
|
||
"""
|
||
statuses = _runner_online_map(base_url=base_url, sessions=sessions)
|
||
annotated: list[_HostSessionRow] = []
|
||
for session in sessions:
|
||
runner_id = session.get("runner_id")
|
||
runner_online = statuses.get(runner_id) if isinstance(runner_id, str) else None
|
||
annotated.append({**session, "runner_online": runner_online})
|
||
return annotated
|
||
|
||
|
||
def _base_daemon_status_payload(record: _HostDaemonRecord) -> _HostPayload:
|
||
"""
|
||
Build daemon metadata for status output.
|
||
|
||
:param record: Daemon registry record to inspect.
|
||
:returns: JSON-serializable daemon metadata.
|
||
"""
|
||
base_url = _daemon_base_url(record)
|
||
host_id = record.host_id or _load_existing_host_id()
|
||
return {
|
||
"target": record.target,
|
||
"mode": record.mode,
|
||
"server_url": base_url,
|
||
"pid": record.pid,
|
||
"process": "online" if _pid_alive(record.pid) else "offline",
|
||
"log_path": record.log_path,
|
||
"host_id": host_id,
|
||
"host_status": None,
|
||
"sessions": [],
|
||
"error": None,
|
||
}
|
||
|
||
|
||
def _add_daemon_host_status(
|
||
payload: _HostPayload,
|
||
) -> None:
|
||
"""
|
||
Add host status or host status error to a daemon payload.
|
||
|
||
:param payload: Payload from :func:`_base_daemon_status_payload`.
|
||
"""
|
||
base_url = payload.get("server_url")
|
||
host_id = payload.get("host_id")
|
||
if not isinstance(base_url, str):
|
||
payload["error"] = "local Omnigent server is not reachable"
|
||
return
|
||
if not isinstance(host_id, str) or not host_id:
|
||
payload["error"] = "host id is not available in local config"
|
||
return
|
||
from omnigent.claude_native_bridge import url_component
|
||
|
||
host_result = _host_http_json(
|
||
base_url=base_url,
|
||
method="GET",
|
||
path=f"/v1/hosts/{url_component(host_id)}",
|
||
)
|
||
if host_result.status_code == 200 and isinstance(host_result.body, dict):
|
||
status = host_result.body.get("status")
|
||
payload["host_status"] = status if isinstance(status, str) else None
|
||
elif host_result.status_code == 0:
|
||
payload["error"] = f"host status failed: {_host_error_text(host_result.body)}"
|
||
elif host_result.status_code >= 400:
|
||
payload["error"] = (
|
||
f"host status failed ({host_result.status_code}): {_host_error_text(host_result.body)}"
|
||
)
|
||
|
||
|
||
def _add_daemon_sessions(
|
||
payload: _HostPayload,
|
||
record: _HostDaemonRecord,
|
||
*,
|
||
connected_sessions_only: bool,
|
||
) -> None:
|
||
"""
|
||
Add owned sessions and runner connectivity to a daemon payload.
|
||
|
||
:param payload: Payload from :func:`_base_daemon_status_payload`.
|
||
:param record: Daemon registry record to inspect.
|
||
:param connected_sessions_only: Whether session listing should use
|
||
the server's connected filter.
|
||
"""
|
||
sessions_result = _sessions_for_daemon(
|
||
record,
|
||
connected_only=connected_sessions_only,
|
||
)
|
||
sessions = sessions_result.sessions
|
||
if sessions_result.base_url is not None and sessions:
|
||
sessions = _annotate_sessions_with_runner_online(
|
||
base_url=sessions_result.base_url,
|
||
sessions=sessions,
|
||
)
|
||
payload["sessions"] = cast(_HostJsonValue, sessions)
|
||
if sessions_result.error is not None and payload["error"] is None:
|
||
payload["error"] = sessions_result.error
|
||
|
||
|
||
def _daemon_status_payload(
|
||
record: _HostDaemonRecord,
|
||
*,
|
||
include_sessions: bool,
|
||
connected_sessions_only: bool,
|
||
) -> _HostPayload:
|
||
"""
|
||
Build a display payload for one daemon.
|
||
|
||
:param record: Daemon registry record to inspect.
|
||
:param include_sessions: Whether to include session rows.
|
||
:param connected_sessions_only: Whether session listing should use
|
||
the server's connected filter.
|
||
:returns: JSON-serializable status payload.
|
||
"""
|
||
payload = _base_daemon_status_payload(record)
|
||
_add_daemon_host_status(payload)
|
||
if include_sessions:
|
||
_add_daemon_sessions(
|
||
payload,
|
||
record,
|
||
connected_sessions_only=connected_sessions_only,
|
||
)
|
||
return payload
|
||
|
||
|
||
def _host_console() -> Console:
|
||
"""
|
||
Build the Rich console used by host management output.
|
||
|
||
:returns: A :class:`rich.console.Console` configured for predictable
|
||
CLI rendering.
|
||
"""
|
||
return Console(highlight=False)
|
||
|
||
|
||
def _host_table(title: str) -> Table:
|
||
"""
|
||
Build a host CLI table with the shared style.
|
||
|
||
:param title: Table title, e.g. ``"Host daemons"``.
|
||
:returns: A :class:`rich.table.Table` ready for columns and rows.
|
||
"""
|
||
return Table(
|
||
title=title,
|
||
box=box.SIMPLE_HEAVY,
|
||
border_style="dim",
|
||
header_style="bold cyan",
|
||
show_edge=False,
|
||
)
|
||
|
||
|
||
def _host_display_value(value: _HostJsonValue, *, missing: str = "-") -> str:
|
||
"""
|
||
Convert optional payload values into display text.
|
||
|
||
:param value: Payload value, e.g. ``None`` or ``"runner_abc"``.
|
||
:param missing: Text to use when *value* is absent, e.g. ``"-"``.
|
||
:returns: Display string.
|
||
"""
|
||
if value is None:
|
||
return missing
|
||
text = str(value)
|
||
return text if text else missing
|
||
|
||
|
||
def _host_shorten(text: _HostJsonValue, *, max_chars: int) -> str:
|
||
"""
|
||
Shorten long daemon, session, and runner identifiers for terminal display.
|
||
|
||
:param text: Value to shorten, e.g. ``"conv_abcdef123456"``.
|
||
:param max_chars: Maximum display width, e.g. ``24``.
|
||
:returns: The original text if it fits, otherwise a middle-truncated
|
||
string.
|
||
"""
|
||
value = _host_display_value(text)
|
||
if len(value) <= max_chars:
|
||
return value
|
||
if max_chars <= 1:
|
||
return value[:max_chars]
|
||
head = max(1, (max_chars - 1) // 2)
|
||
tail = max(1, max_chars - head - 1)
|
||
return f"{value[:head]}…{value[-tail:]}"
|
||
|
||
|
||
def _host_truncate(text: _HostJsonValue, *, max_chars: int) -> str:
|
||
"""
|
||
Truncate long text from the right for compact terminal display.
|
||
|
||
:param text: Value to truncate, e.g. an Omnigent error message.
|
||
:param max_chars: Maximum display width, e.g. ``96``.
|
||
:returns: The original text if it fits, otherwise a right-truncated
|
||
string ending in an ellipsis.
|
||
"""
|
||
value = _host_display_value(text)
|
||
if len(value) <= max_chars:
|
||
return value
|
||
if max_chars <= 1:
|
||
return value[:max_chars]
|
||
return f"{value[: max_chars - 1]}…"
|
||
|
||
|
||
def _host_markup(text: _HostJsonValue, *, missing: str = "-") -> str:
|
||
"""
|
||
Escape dynamic values before embedding them in Rich markup.
|
||
|
||
:param text: Value to render, e.g. a session title containing ``"["``.
|
||
:param missing: Text to use when *text* is absent, e.g. ``"-"``.
|
||
:returns: Markup-safe display text.
|
||
"""
|
||
from rich.markup import escape
|
||
|
||
return escape(_host_display_value(text, missing=missing))
|
||
|
||
|
||
def _host_target_label(payload: _HostPayload, *, width: int) -> str:
|
||
"""
|
||
Build a compact daemon target label.
|
||
|
||
:param payload: Payload from :func:`_daemon_status_payload`.
|
||
:param width: Maximum label width, e.g. ``48``.
|
||
:returns: Compact target label for headers and error rows.
|
||
"""
|
||
target = _host_display_value(payload.get("target"))
|
||
server_url = payload.get("server_url")
|
||
if target == _LOCAL_DAEMON_MARKER and server_url:
|
||
target = f"local ({server_url})"
|
||
return _host_shorten(target, max_chars=width)
|
||
|
||
|
||
def _host_status_style(value: _HostJsonValue) -> str:
|
||
"""
|
||
Pick a Rich style for a daemon, host, or session status.
|
||
|
||
:param value: Status value, e.g. ``"online"``, ``"idle"``, or
|
||
``"failed"``.
|
||
:returns: Rich style name for the value.
|
||
"""
|
||
status = _host_display_value(value).lower()
|
||
if status in {"online", "connected", "running", "idle"}:
|
||
return "green"
|
||
if status in {"offline", "failed", "error", "unknown"}:
|
||
return "red"
|
||
return "yellow"
|
||
|
||
|
||
def _host_runner_state(session: _HostSessionRow) -> str:
|
||
"""
|
||
Return a display state for the session's bound runner.
|
||
|
||
:param session: Session row, e.g.
|
||
``{"runner_id": "runner_abc", "runner_online": True}``.
|
||
:returns: ``"online"``, ``"offline"``, or ``"unknown"``.
|
||
"""
|
||
runner_id = session.get("runner_id")
|
||
if not isinstance(runner_id, str) or not runner_id:
|
||
return "unknown"
|
||
runner_online = session.get("runner_online")
|
||
if runner_online is True:
|
||
return "online"
|
||
if runner_online is False:
|
||
return "offline"
|
||
return "unknown"
|
||
|
||
|
||
def _host_sessions_table_widths(
|
||
*, console_width: int, sessions: list[_HostJsonValue]
|
||
) -> _HostSessionsTableWidths:
|
||
"""
|
||
Compute compact sessions table widths for the available terminal space.
|
||
|
||
:param console_width: Console width in cells, e.g. ``120``.
|
||
:param sessions: Raw session payloads from status data.
|
||
:returns: Column widths that prefer full IDs when they fit.
|
||
"""
|
||
rows = [session for session in sessions if isinstance(session, dict)]
|
||
full_session_id = max(
|
||
[len("Session ID"), *[len(_host_display_value(row.get("id"))) for row in rows]]
|
||
)
|
||
full_runner_id = max(
|
||
[len("Runner ID"), *[len(_host_display_value(row.get("runner_id"))) for row in rows]]
|
||
)
|
||
min_title = 12
|
||
# Padding, separators, and the fixed State / Runner columns consume
|
||
# space that is not represented by the three variable-width columns.
|
||
table_chrome = 34
|
||
full_ids_fit = console_width >= full_session_id + full_runner_id + min_title + table_chrome
|
||
session_id = full_session_id if full_ids_fit else min(full_session_id, 18)
|
||
runner_id = full_runner_id if full_ids_fit else min(full_runner_id, 20)
|
||
title = max(min_title, min(console_width - session_id - runner_id - table_chrome, 60))
|
||
workspace = 48 if console_width >= session_id + runner_id + title + table_chrome + 50 else None
|
||
return _HostSessionsTableWidths(
|
||
session_id=session_id,
|
||
runner_id=runner_id,
|
||
title=title,
|
||
workspace=workspace,
|
||
)
|
||
|
||
|
||
def _add_host_payload_sessions_table(console: Console, payload: _HostPayload) -> None:
|
||
"""
|
||
Render one daemon's owned sessions as a compact table.
|
||
|
||
:param console: Rich console returned by :func:`_host_console`.
|
||
:param payload: Payload from :func:`_daemon_status_payload`.
|
||
"""
|
||
raw_sessions = payload.get("sessions")
|
||
sessions = raw_sessions if isinstance(raw_sessions, list) else []
|
||
if not sessions:
|
||
console.print(" [dim]No owned sessions found.[/dim]")
|
||
return
|
||
table = _host_table("Sessions")
|
||
widths = _host_sessions_table_widths(console_width=console.width, sessions=sessions)
|
||
table.add_column(
|
||
"Session ID",
|
||
style="bold",
|
||
overflow="ellipsis",
|
||
no_wrap=True,
|
||
max_width=widths.session_id,
|
||
)
|
||
table.add_column("State", width=7, no_wrap=True)
|
||
table.add_column("Runner", width=7, no_wrap=True)
|
||
table.add_column(
|
||
"Runner ID",
|
||
overflow="ellipsis",
|
||
no_wrap=True,
|
||
max_width=widths.runner_id,
|
||
)
|
||
table.add_column(
|
||
"Title",
|
||
overflow="ellipsis",
|
||
no_wrap=True,
|
||
max_width=widths.title,
|
||
)
|
||
if widths.workspace is not None:
|
||
table.add_column(
|
||
"Workspace",
|
||
overflow="ellipsis",
|
||
no_wrap=True,
|
||
max_width=widths.workspace,
|
||
)
|
||
for session in sessions:
|
||
if not isinstance(session, dict):
|
||
continue
|
||
session_row = session
|
||
status = _host_display_value(session_row.get("status"), missing="unknown")
|
||
runner_state = _host_runner_state(session_row)
|
||
row = [
|
||
_host_shorten(session_row.get("id"), max_chars=widths.session_id),
|
||
f"[{_host_status_style(status)}]{status}[/]",
|
||
f"[{_host_status_style(runner_state)}]{runner_state}[/]",
|
||
_host_shorten(session_row.get("runner_id"), max_chars=widths.runner_id),
|
||
_host_truncate(
|
||
session_row.get("title"),
|
||
max_chars=widths.title,
|
||
),
|
||
]
|
||
if widths.workspace is not None:
|
||
row.append(_host_shorten(session_row.get("workspace"), max_chars=widths.workspace))
|
||
table.add_row(*row)
|
||
console.print(table)
|
||
|
||
|
||
def _echo_daemon_payloads(payloads: list[_HostPayload]) -> None:
|
||
"""
|
||
Render host status as one block per daemon target.
|
||
|
||
:param payloads: Payloads from :func:`_daemon_status_payload`.
|
||
"""
|
||
console = _host_console()
|
||
if not payloads:
|
||
console.print("[dim]No host daemons found.[/dim]")
|
||
return
|
||
for idx, payload in enumerate(payloads):
|
||
if idx:
|
||
console.print()
|
||
target = _host_target_label(payload, width=max(24, min(console.width - 2, 96)))
|
||
process = _host_display_value(payload.get("process"), missing="unknown")
|
||
host_status = _host_display_value(payload.get("host_status"), missing="unknown")
|
||
console.print(f"[bold cyan]{_host_markup(target)}[/bold cyan]")
|
||
console.print(
|
||
" "
|
||
f"mode={_host_markup(payload.get('mode'))} "
|
||
f"pid={_host_markup(payload.get('pid'))} "
|
||
f"process=[{_host_status_style(process)}]{process}[/] "
|
||
f"host=[{_host_status_style(host_status)}]{host_status}[/]"
|
||
)
|
||
server_text = _host_shorten(
|
||
payload.get("server_url"),
|
||
max_chars=max(24, console.width - 11),
|
||
)
|
||
console.print(f" server={_host_markup(server_text)}")
|
||
console.print(f" host_id={_host_markup(payload.get('host_id'))}")
|
||
if payload.get("log_path"):
|
||
console.print(f" log={_host_markup(payload.get('log_path'))}")
|
||
if payload.get("error"):
|
||
message = _host_truncate(
|
||
payload.get("error"),
|
||
max_chars=max(24, console.width - 10),
|
||
)
|
||
console.print(f" [red]error={_host_markup(message)}[/red]")
|
||
_add_host_payload_sessions_table(console, payload)
|
||
|
||
|
||
@host.command("status")
|
||
@click.option("--server", default=None, help="Inspect only this server target.")
|
||
@click.option("--all", "all_targets", is_flag=True, help="Inspect all known daemon targets.")
|
||
@click.option("--json", "json_output", is_flag=True, help="Emit JSON.")
|
||
@click.pass_context
|
||
def host_status(
|
||
ctx: click.Context,
|
||
server: str | None,
|
||
all_targets: bool,
|
||
json_output: bool,
|
||
) -> None:
|
||
"""
|
||
Inspect host daemon, runner, and session status.
|
||
|
||
:param ctx: Click context carrying group-level options.
|
||
:param server: Optional server target to inspect, e.g.
|
||
``"https://example.databricksapps.com"``.
|
||
:param all_targets: Whether to inspect every known daemon target.
|
||
:param json_output: Whether to emit machine-readable JSON.
|
||
"""
|
||
if server is None:
|
||
server = _host_group_option(ctx, "server")
|
||
records = _selected_daemon_records(server=server, all_targets=all_targets, default_all=True)
|
||
payloads = [
|
||
_daemon_status_payload(
|
||
record,
|
||
include_sessions=True,
|
||
connected_sessions_only=True,
|
||
)
|
||
for record in records
|
||
]
|
||
if json_output:
|
||
click.echo(json.dumps({"daemons": payloads}, indent=2, sort_keys=True))
|
||
return
|
||
_echo_daemon_payloads(payloads)
|
||
|
||
|
||
def _stop_session_on_server(
|
||
*,
|
||
base_url: str,
|
||
session_id: str,
|
||
) -> None:
|
||
"""
|
||
Stop one Omnigent session via the server lifecycle event API.
|
||
|
||
:param base_url: Omnigent server base URL, e.g.
|
||
``"https://example.databricksapps.com"``.
|
||
:param session_id: Session id, e.g. ``"conv_abc123"``.
|
||
:raises click.ClickException: If the server rejects the stop event.
|
||
"""
|
||
from omnigent.claude_native_bridge import url_component
|
||
|
||
result = _host_http_json(
|
||
base_url=base_url,
|
||
method="POST",
|
||
path=f"/v1/sessions/{url_component(session_id)}/events",
|
||
json_body={"type": "stop_session", "data": {}},
|
||
)
|
||
if result.status_code == 0:
|
||
raise click.ClickException(
|
||
f"Failed to stop session {session_id!r}: {_host_error_text(result.body)}"
|
||
)
|
||
if result.status_code >= 400:
|
||
raise click.ClickException(
|
||
f"Failed to stop session {session_id!r} ({result.status_code}): "
|
||
f"{_host_error_text(result.body)}"
|
||
)
|
||
|
||
|
||
def _stop_daemon_sessions(
|
||
record: _HostDaemonRecord,
|
||
*,
|
||
force: bool,
|
||
) -> int:
|
||
"""
|
||
Stop sessions owned by a daemon before terminating it.
|
||
|
||
:param record: Daemon record whose host-bound sessions should stop.
|
||
:param force: Continue stopping remaining sessions after failures.
|
||
:returns: Number of sessions successfully stopped.
|
||
:raises click.ClickException: If session listing or stop fails and
|
||
``force`` is ``False``.
|
||
"""
|
||
result = _sessions_for_daemon(record)
|
||
if result.error is not None:
|
||
if force:
|
||
click.echo(f"{record.target}: skipping session stop: {result.error}", err=True)
|
||
return 0
|
||
raise click.ClickException(f"{record.target}: {result.error}")
|
||
if result.base_url is None:
|
||
return 0
|
||
stopped = 0
|
||
for session in result.sessions:
|
||
session_id = session.get("id")
|
||
if not isinstance(session_id, str) or not session_id:
|
||
continue
|
||
try:
|
||
_stop_session_on_server(
|
||
base_url=result.base_url,
|
||
session_id=session_id,
|
||
)
|
||
except click.ClickException as exc:
|
||
if not force:
|
||
raise
|
||
click.echo(str(exc), err=True)
|
||
continue
|
||
stopped += 1
|
||
return stopped
|
||
|
||
|
||
def _terminate_daemon(record: _HostDaemonRecord, *, force: bool) -> None:
|
||
"""
|
||
Terminate one local daemon process.
|
||
|
||
:param record: Daemon record whose process should terminate.
|
||
:param force: Send SIGKILL after the SIGTERM grace period.
|
||
:raises click.ClickException: If the process stays alive.
|
||
"""
|
||
if not _pid_alive(record.pid):
|
||
_delete_daemon_record(record)
|
||
return
|
||
with contextlib.suppress(ProcessLookupError):
|
||
os.kill(record.pid, signal.SIGTERM)
|
||
deadline = time.monotonic() + _HOST_DAEMON_STOP_GRACE_S
|
||
while time.monotonic() < deadline:
|
||
if not _pid_alive(record.pid):
|
||
_delete_daemon_record(record)
|
||
return
|
||
time.sleep(0.1)
|
||
if force:
|
||
with contextlib.suppress(ProcessLookupError):
|
||
os.kill(record.pid, getattr(signal, "SIGKILL", signal.SIGTERM))
|
||
deadline = time.monotonic() + 2.0
|
||
while time.monotonic() < deadline:
|
||
if not _pid_alive(record.pid):
|
||
_delete_daemon_record(record)
|
||
return
|
||
time.sleep(0.1)
|
||
raise click.ClickException(
|
||
f"Daemon {record.pid} for {record.target!r} did not exit; retry with --force."
|
||
)
|
||
|
||
|
||
@host.command("stop")
|
||
@click.option("--server", default=None, help="Stop only this server target.")
|
||
@click.option("--all", "all_targets", is_flag=True, help="Stop all known daemon targets.")
|
||
@click.option(
|
||
"--daemon-only",
|
||
is_flag=True,
|
||
help="Terminate daemon processes without first stopping sessions.",
|
||
)
|
||
@click.option("--force", is_flag=True, help="Continue after failures and use SIGKILL if needed.")
|
||
@click.pass_context
|
||
def host_stop(
|
||
ctx: click.Context,
|
||
server: str | None,
|
||
all_targets: bool,
|
||
daemon_only: bool,
|
||
force: bool,
|
||
) -> None:
|
||
"""
|
||
Stop host daemon sessions, then stop daemon processes.
|
||
|
||
:param ctx: Click context carrying group-level options.
|
||
:param server: Optional server target to stop, e.g.
|
||
``"https://example.databricksapps.com"``.
|
||
:param all_targets: Whether to stop every known daemon target.
|
||
:param daemon_only: Skip server-side session stop calls when ``True``.
|
||
:param force: Continue after failures and use SIGKILL if needed.
|
||
"""
|
||
if server is None:
|
||
server = _host_group_option(ctx, "server")
|
||
records = _selected_daemon_records(server=server, all_targets=all_targets, default_all=False)
|
||
if not records:
|
||
click.echo("No matching host daemon found.")
|
||
return
|
||
for record in records:
|
||
stopped = 0
|
||
if not daemon_only:
|
||
stopped = _stop_daemon_sessions(record, force=force)
|
||
_terminate_daemon(record, force=force)
|
||
click.echo(f"Stopped {record.target} daemon pid={record.pid}; sessions_stopped={stopped}.")
|
||
|
||
|
||
@host.command("stop-session")
|
||
@click.argument("session_ids", nargs=-1, required=True)
|
||
@click.option("--server", default=None, help="Server that owns the sessions.")
|
||
@click.option("--force", is_flag=True, help="Continue after individual stop failures.")
|
||
@click.pass_context
|
||
def host_stop_session(
|
||
ctx: click.Context,
|
||
session_ids: Sequence[str],
|
||
server: str | None,
|
||
force: bool,
|
||
) -> None:
|
||
"""
|
||
Stop specific sessions without stopping a daemon.
|
||
|
||
:param ctx: Click context carrying group-level options.
|
||
:param session_ids: Session ids to stop, e.g.
|
||
``["conv_abc123", "conv_def456"]``.
|
||
:param server: Omnigent server URL that owns the sessions, e.g.
|
||
``"https://example.databricksapps.com"``. ``None`` falls back
|
||
to config/local discovery.
|
||
:param force: Continue after individual stop failures.
|
||
"""
|
||
if server is None:
|
||
server = _host_group_option(ctx, "server")
|
||
resolved_server = _resolve_host_server(server)
|
||
if resolved_server is None:
|
||
resolved_server = local_server_url_if_healthy()
|
||
if resolved_server is None:
|
||
raise click.ClickException(
|
||
"No server was supplied and no local Omnigent server is reachable."
|
||
)
|
||
for session_id in session_ids:
|
||
try:
|
||
_stop_session_on_server(
|
||
base_url=resolved_server,
|
||
session_id=session_id,
|
||
)
|
||
except click.ClickException:
|
||
if not force:
|
||
raise
|
||
click.echo(f"Failed to stop session {session_id!r}.", err=True)
|
||
continue
|
||
click.echo(f"Stopped session {session_id}.")
|
||
|
||
|
||
@cli.command(hidden=True)
|
||
def version() -> None:
|
||
"""Print the installed Omnigent version."""
|
||
print(_format_version())
|
||
|
||
|
||
def _parse_config_settings(
|
||
settings: tuple[str, ...],
|
||
*,
|
||
resolve_paths: bool = False,
|
||
) -> dict[str, str | bool]:
|
||
"""
|
||
Parse and validate ``KEY=VALUE`` pairs from the ``config`` command.
|
||
|
||
Raises :class:`click.ClickException` for malformed items or unknown keys.
|
||
|
||
:param settings: Raw ``KEY=VALUE`` strings, e.g.
|
||
``("default_agent=examples/hello.yaml", "model=gpt-5.4-mini")``.
|
||
:param resolve_paths: When ``True``, resolve relative ``default_agent``
|
||
paths to absolute so the config works regardless of working directory.
|
||
Set for ``--global`` writes; leave ``False`` for project-local writes
|
||
where the path is intentionally relative to the project root.
|
||
:returns: Validated mapping of config key → value, e.g.
|
||
``{"agent": "examples/hello.yaml", "model": "gpt-5.4-mini"}``.
|
||
"""
|
||
parsed: dict[str, str | bool] = {}
|
||
for item in settings:
|
||
if "=" not in item:
|
||
raise click.ClickException(
|
||
f"Expected KEY=VALUE, got: {item!r}. "
|
||
"Example: omnigent config set --global default_agent=myagent.yaml"
|
||
)
|
||
key, _, value = item.partition("=")
|
||
if key not in _GLOBAL_CONFIG_KEYS:
|
||
raise click.ClickException(
|
||
f"Unknown config key {key!r}. "
|
||
f"Supported keys: {', '.join(sorted(_GLOBAL_CONFIG_KEYS))}"
|
||
)
|
||
# Resolve ``default_agent`` to an absolute path so ``omnigent`` works from
|
||
# any working directory, not just the directory where config was set.
|
||
if (
|
||
resolve_paths
|
||
and key == "default_agent"
|
||
and not value.startswith(("http://", "https://"))
|
||
):
|
||
value = str(Path(value).resolve())
|
||
if key in _BOOLEAN_CONFIG_KEYS:
|
||
parsed[key] = _parse_config_bool(key, value)
|
||
else:
|
||
parsed[key] = value
|
||
return parsed
|
||
|
||
|
||
def _validate_unset_keys(unset_keys: tuple[str, ...]) -> list[str]:
|
||
"""
|
||
Validate keys passed to ``--unset`` against ``_GLOBAL_CONFIG_KEYS``.
|
||
|
||
Raises :class:`click.ClickException` for any unrecognised key.
|
||
|
||
:param unset_keys: Keys to remove from global config, e.g.
|
||
``("server",)``.
|
||
:returns: The same keys as a list, confirming they are all valid.
|
||
"""
|
||
validated: list[str] = []
|
||
for key in unset_keys:
|
||
if key not in _GLOBAL_CONFIG_KEYS:
|
||
raise click.ClickException(
|
||
f"Unknown config key {key!r}. "
|
||
f"Supported keys: {', '.join(sorted(_GLOBAL_CONFIG_KEYS))}"
|
||
)
|
||
validated.append(key)
|
||
return validated
|
||
|
||
|
||
def _print_config_defaults() -> None:
|
||
"""Print the effective CLI defaults (user + project-level).
|
||
|
||
The ``KEY=VALUE`` defaults from ``~/.omnigent/config.yaml`` (user) and
|
||
``.omnigent/config.yaml`` in the cwd (project, takes precedence).
|
||
Used by ``omnigent config list``.
|
||
|
||
:returns: None. Side effect: writes to stdout.
|
||
"""
|
||
# Only the user-facing run defaults (the keys ``config set`` accepts).
|
||
# Internal blocks (``providers``, ``host``, ``tui``) are omitted — the
|
||
# ``providers`` block is shown in the credentials-by-harness section.
|
||
global_cfg = {k: v for k, v in _load_global_config().items() if k in _GLOBAL_CONFIG_KEYS}
|
||
local_cfg = {k: v for k, v in _load_local_config().items() if k in _GLOBAL_CONFIG_KEYS}
|
||
if not global_cfg and not local_cfg:
|
||
click.echo(
|
||
" (none set — `omnigent config set key=value` for project,\n"
|
||
" or `omnigent config set --global key=value` for user-level)"
|
||
)
|
||
return
|
||
global_path = _effective_global_config_path()
|
||
local_path = Path.cwd() / _LOCAL_CONFIG_RELPATH
|
||
# When the cwd IS the home directory, the project-level path
|
||
# (``cwd/.omnigent/config.yaml``) resolves to the SAME file as the
|
||
# user-level path (``~/.omnigent/config.yaml``). Dedup on the resolved
|
||
# absolute path so the one file is shown once, not twice under two
|
||
# spellings. ``resolve()`` collapses ``~`` and symlinks for the compare.
|
||
local_is_global = local_cfg and local_path.resolve() == global_path.resolve()
|
||
if global_cfg:
|
||
click.echo(f" # {_display_config_path(global_path)}")
|
||
for k, v in sorted(global_cfg.items()):
|
||
click.echo(f" {k}={v}")
|
||
if local_cfg and not local_is_global:
|
||
click.echo(f" # {local_path}")
|
||
for k, v in sorted(local_cfg.items()):
|
||
click.echo(f" {k}={v}")
|
||
|
||
|
||
class _ConfigGroup(click.Group):
|
||
"""``config`` group that nudges the pre-split flat form to the subcommands.
|
||
|
||
Before the noun-verb split, ``config`` took a positional ``KEY=VALUE``
|
||
plus ``--list`` / ``--unset`` / ``--global`` flags. Those now live under
|
||
``config set`` / ``config list`` / ``config unset``. Click's default
|
||
error for the old form is opaque (``No such command 'x=y'`` / ``No such
|
||
option: --list``), so this intercepts the legacy first token and raises
|
||
a hint pointing at the new command instead.
|
||
"""
|
||
|
||
@staticmethod
|
||
def _legacy_hint(first: str) -> str | None:
|
||
"""Return a migration hint for a legacy first token, else ``None``.
|
||
|
||
:param first: The first CLI token after ``config``, e.g.
|
||
``"--list"`` or ``"model=gpt-5.4-mini"``.
|
||
:returns: A hint string for a recognized legacy form, else ``None``.
|
||
"""
|
||
if first == "--list":
|
||
return "`config --list` is now `omnigent config list`."
|
||
if first == "--unset":
|
||
return "`config --unset KEY` is now `omnigent config unset KEY`."
|
||
if first == "--global":
|
||
return (
|
||
"`--global` now goes on the subcommand — "
|
||
"`omnigent config set --global KEY=VALUE` or "
|
||
"`omnigent config unset --global KEY`."
|
||
)
|
||
if "=" in first and not first.startswith("-"):
|
||
return f"setting defaults is now `omnigent config set {first}`."
|
||
return None
|
||
|
||
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
|
||
"""Intercept the legacy flat form before normal group parsing.
|
||
|
||
:param ctx: The click context.
|
||
:param args: Raw argument tokens after ``config``.
|
||
:returns: The remaining args from the base parser (for valid forms).
|
||
:raises click.UsageError: When the first token is a legacy form, with
|
||
a hint pointing at the new ``config set`` / ``list`` / ``unset``.
|
||
"""
|
||
# Only the FIRST token is inspected: a known subcommand (set/list/
|
||
# unset) parses normally — so ``config set default_agent=x`` is not
|
||
# mistaken for the legacy ``config default_agent=x``.
|
||
if args and args[0] not in self.commands:
|
||
hint = self._legacy_hint(args[0])
|
||
if hint is not None:
|
||
raise click.UsageError(hint)
|
||
return super().parse_args(ctx, args)
|
||
|
||
|
||
@cli.group("config", cls=_ConfigGroup)
|
||
def config_grp() -> None:
|
||
"""Get, set, and view Omnigent defaults and credentials.
|
||
|
||
Defaults (auto_open_conversation, default_agent, harness, model,
|
||
server) are used by ``omnigent run``. Project-level config
|
||
(``.omnigent/config.yaml`` in the cwd, like ``.git/config``) overrides
|
||
user-level config (``~/.omnigent/config.yaml``, like ``~/.gitconfig``).
|
||
|
||
\b
|
||
Subcommands:
|
||
list Show the effective defaults + configured credentials (by harness).
|
||
set Set one or more defaults (KEY=VALUE).
|
||
unset Remove one or more defaults.
|
||
"""
|
||
|
||
|
||
@config_grp.command("list")
|
||
def config_list() -> None:
|
||
"""List the effective defaults and configured credentials.
|
||
|
||
Prints the defaults (user + project), then the configured model
|
||
credentials grouped by harness with each harness's default marked — the
|
||
merged view of everything ``omnigent run`` will use (including
|
||
ambient-detected credentials).
|
||
|
||
:returns: None.
|
||
"""
|
||
click.echo("Defaults")
|
||
_print_config_defaults()
|
||
click.echo()
|
||
_print_credentials_by_harness()
|
||
|
||
|
||
@config_grp.command("set")
|
||
@click.option(
|
||
"--global",
|
||
"is_global",
|
||
is_flag=True,
|
||
default=False,
|
||
help="Write to ~/.omnigent/config.yaml (user-level) instead of the project config.",
|
||
)
|
||
@click.argument("settings", nargs=-1, required=True, metavar="KEY=VALUE...")
|
||
def config_set(is_global: bool, settings: tuple[str, ...]) -> None:
|
||
"""Set one or more Omnigent defaults.
|
||
|
||
Without ``--global``, pairs are written to ``.omnigent/config.yaml``
|
||
in the current directory (project-level, like ``.git/config``); with
|
||
``--global`` to ``~/.omnigent/config.yaml`` (user-level, like
|
||
``~/.gitconfig``). Project values take precedence.
|
||
|
||
Supported keys: auto_open_conversation, default_agent, harness,
|
||
model, server.
|
||
|
||
:param is_global: When ``True``, write to ``~/.omnigent/config.yaml``;
|
||
when ``False``, to ``.omnigent/config.yaml`` in cwd.
|
||
:param settings: ``KEY=VALUE`` pairs to set, e.g.
|
||
``("default_agent=examples/hello.yaml", "model=gpt-5.4-mini")``.
|
||
|
||
\b
|
||
Examples:
|
||
omnigent config set default_agent=examples/hello_world.yaml
|
||
omnigent config set --global server=https://<app>.databricksapps.com
|
||
"""
|
||
if is_global:
|
||
parsed = _parse_config_settings(settings, resolve_paths=True)
|
||
_save_global_config(parsed, ())
|
||
config_path: Path = _effective_global_config_path()
|
||
else:
|
||
parsed = _parse_config_settings(settings, resolve_paths=False)
|
||
_save_local_config(parsed, ())
|
||
config_path = Path.cwd() / _LOCAL_CONFIG_RELPATH
|
||
click.echo(f"Set {len(parsed)} key(s) in {config_path}")
|
||
|
||
|
||
@config_grp.command("unset")
|
||
@click.option(
|
||
"--global",
|
||
"is_global",
|
||
is_flag=True,
|
||
default=False,
|
||
help="Remove from ~/.omnigent/config.yaml (user-level) instead of the project config.",
|
||
)
|
||
@click.argument("keys", nargs=-1, required=True, metavar="KEY...")
|
||
def config_unset(is_global: bool, keys: tuple[str, ...]) -> None:
|
||
"""Remove one or more Omnigent defaults.
|
||
|
||
:param is_global: When ``True``, remove from ``~/.omnigent/config.yaml``;
|
||
when ``False``, from ``.omnigent/config.yaml`` in cwd.
|
||
:param keys: Keys to remove, e.g. ``("server", "model")``.
|
||
"""
|
||
validated = _validate_unset_keys(keys)
|
||
if is_global:
|
||
_save_global_config({}, tuple(validated))
|
||
config_path: Path = _effective_global_config_path()
|
||
else:
|
||
_save_local_config({}, tuple(validated))
|
||
config_path = Path.cwd() / _LOCAL_CONFIG_RELPATH
|
||
click.echo(f"Unset {len(validated)} key(s) from {config_path}")
|
||
|
||
|
||
# Node version hint shared by the preflight problem messages and surfaced
|
||
# to the user. The Node-based harness CLIs (Claude Code, Codex, Pi) bundle
|
||
# a copy of ``undici`` that calls ``worker_threads.markAsUncloneable`` — a
|
||
# Node API added in 22.10 that is absent from every 20.x release. On older
|
||
# Node it surfaces as the opaque
|
||
# ``TypeError: webidl.util.markAsUncloneable is not a function``.
|
||
_NODE_MIN_VERSION_HINT = "Node.js 22 LTS or newer (a 22.10+ API is required)"
|
||
|
||
|
||
def _node_version(node_path: str) -> str | None:
|
||
"""
|
||
Return the ``node --version`` string (e.g. ``v20.12.2``) or ``None``.
|
||
|
||
Used only to make the "too old" warning concrete; a failure to read the
|
||
version is non-fatal — the caller still reports the underlying problem.
|
||
|
||
:param node_path: Absolute path to the ``node`` binary, as resolved by
|
||
:func:`shutil.which`.
|
||
:returns: The trimmed version string, or ``None`` if ``node`` could not
|
||
be invoked.
|
||
"""
|
||
try:
|
||
result = subprocess.run(
|
||
[node_path, "--version"],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=10,
|
||
check=False,
|
||
)
|
||
except (OSError, subprocess.TimeoutExpired):
|
||
return None
|
||
return result.stdout.strip() or None
|
||
|
||
|
||
def _node_dependency_problem() -> str | None:
|
||
"""
|
||
Return a one-line problem if Node is missing or too old, else ``None``.
|
||
|
||
The Node-based harnesses (``claude-native``, ``codex``, ``pi``) shell
|
||
out to CLIs that bundle ``undici``; that bundle calls
|
||
``worker_threads.markAsUncloneable`` (added in Node 22.10). We invoke
|
||
``node`` to probe for the symbol directly rather than parse
|
||
``node --version``, so the check tracks the actual capability across
|
||
the 22.x/23.x version split and never goes stale against a hardcoded
|
||
floor.
|
||
|
||
:returns: A human-readable description suitable for a warning bullet,
|
||
or ``None`` when Node is present and new enough. A flaky/timed-out
|
||
probe also yields ``None`` — setup should not block on it.
|
||
"""
|
||
node = shutil.which("node")
|
||
if node is None:
|
||
return f"node not found — Claude, Codex, and Pi need {_NODE_MIN_VERSION_HINT}."
|
||
# Probe the exact API the bundled undici calls. Exit 0 ⇒ capability
|
||
# present; exit 1 ⇒ too old; we treat any other failure as inconclusive.
|
||
probe = (
|
||
"process.exit("
|
||
"typeof require('node:worker_threads').markAsUncloneable === 'function' ? 0 : 1)"
|
||
)
|
||
try:
|
||
result = subprocess.run(
|
||
[node, "-e", probe],
|
||
capture_output=True,
|
||
timeout=10,
|
||
check=False,
|
||
)
|
||
except (OSError, subprocess.TimeoutExpired):
|
||
return None
|
||
if result.returncode == 0:
|
||
return None
|
||
version = _node_version(node)
|
||
detected = f" (detected {version})" if version else ""
|
||
return f"Node.js is too old{detected} — Claude, Codex, and Pi need {_NODE_MIN_VERSION_HINT}."
|
||
|
||
|
||
@contextlib.contextmanager
|
||
def _isolated_databricks_cfg() -> collections.abc.Generator[None, None, None]:
|
||
"""Run Databricks setup against a temp config containing only our three profiles.
|
||
|
||
The temp file starts with just the canonical internal-beta profile
|
||
sections (see ``DEFAULT_PROFILES``) seeded from the original when they
|
||
exist, so there is exactly one section per workspace host and
|
||
``databricks auth token --host X`` never hits the "multiple profiles
|
||
match" ambiguity error.
|
||
|
||
The user's real config is never modified while this context is active.
|
||
On normal exit the three sections are merged back into the original.
|
||
On SIGTERM / SIGINT the temp file is removed and the original is left
|
||
exactly as it was. SIGKILL cannot be caught, but the original is
|
||
always safe because we never touch it.
|
||
|
||
Uses ``DATABRICKS_CONFIG_FILE`` so both subprocess CLI calls *and*
|
||
the direct configparser writes in ``omnigent.onboarding.setup``
|
||
(via ``_databrickscfg_path()``) all operate on the temp file. Also
|
||
strips every entry in ``CONFLICTING_ENV_VARS`` for the duration of
|
||
the context so a stale Databricks credential env var (see that list)
|
||
can't shadow ``--host`` inside ``databricks auth token``.
|
||
"""
|
||
import configparser
|
||
import signal
|
||
import tempfile
|
||
|
||
from omnigent.onboarding.internal_beta import DEFAULT_PROFILES
|
||
from omnigent.onboarding.setup import CONFLICTING_ENV_VARS
|
||
|
||
original_cfg = Path.home() / ".databrickscfg"
|
||
saved_env: dict[str, str | None] = {
|
||
"DATABRICKS_CONFIG_FILE": os.environ.get("DATABRICKS_CONFIG_FILE"),
|
||
}
|
||
for var in CONFLICTING_ENV_VARS:
|
||
saved_env[var] = os.environ.pop(var, None)
|
||
|
||
def _restore_env() -> None:
|
||
for var, prev in saved_env.items():
|
||
if prev is None:
|
||
os.environ.pop(var, None)
|
||
else:
|
||
os.environ[var] = prev
|
||
|
||
# Temp file contains only the canonical internal-beta profile sections
|
||
# (see DEFAULT_PROFILES), seeded from the original when they already
|
||
# exist. Everything else is excluded so there is exactly one
|
||
# section per workspace host and `databricks auth token --host X`
|
||
# never hits the "multiple profiles match" ambiguity error.
|
||
orig_cfg = configparser.ConfigParser()
|
||
if original_cfg.exists():
|
||
orig_cfg.read(original_cfg)
|
||
cfg = configparser.ConfigParser()
|
||
for spec in DEFAULT_PROFILES:
|
||
if orig_cfg.has_section(spec.name):
|
||
cfg[spec.name] = dict(orig_cfg[spec.name])
|
||
|
||
omnigent_dir = Path.home() / ".omnigent"
|
||
omnigent_dir.mkdir(exist_ok=True)
|
||
tmp_fd, tmp_name = tempfile.mkstemp(
|
||
prefix="databrickscfg-setup-",
|
||
dir=omnigent_dir,
|
||
suffix=".tmp",
|
||
)
|
||
try:
|
||
with os.fdopen(tmp_fd, "w") as f:
|
||
cfg.write(f)
|
||
except Exception:
|
||
os.unlink(tmp_name)
|
||
raise
|
||
tmp_path = Path(tmp_name)
|
||
|
||
os.environ["DATABRICKS_CONFIG_FILE"] = tmp_name
|
||
|
||
def _on_signal(signum: int, _frame: types.FrameType | None) -> None:
|
||
tmp_path.unlink(missing_ok=True)
|
||
_restore_env()
|
||
# Restore the original handler before re-raising so signal chaining
|
||
# (e.g. Click's Ctrl-C → Abort) is preserved rather than falling
|
||
# back to SIG_DFL which would kill the process through the OS.
|
||
signal.signal(signum, prev_sigterm if signum == signal.SIGTERM else prev_sigint)
|
||
signal.raise_signal(signum)
|
||
|
||
prev_sigterm = signal.signal(signal.SIGTERM, _on_signal)
|
||
prev_sigint = signal.signal(signal.SIGINT, _on_signal)
|
||
|
||
write_tmp: Path | None = None
|
||
try:
|
||
yield
|
||
# Merge canonical sections written by setup back into the real cfg.
|
||
tmp_cfg = configparser.ConfigParser()
|
||
tmp_cfg.read(tmp_path)
|
||
orig_cfg = configparser.ConfigParser()
|
||
if original_cfg.exists():
|
||
orig_cfg.read(original_cfg)
|
||
for spec in DEFAULT_PROFILES:
|
||
if tmp_cfg.has_section(spec.name):
|
||
orig_cfg[spec.name] = dict(tmp_cfg[spec.name])
|
||
write_tmp = original_cfg.with_suffix(".tmp")
|
||
with write_tmp.open("w") as f:
|
||
orig_cfg.write(f)
|
||
write_tmp.replace(original_cfg)
|
||
write_tmp = None
|
||
finally:
|
||
tmp_path.unlink(missing_ok=True)
|
||
if write_tmp is not None:
|
||
write_tmp.unlink(missing_ok=True)
|
||
signal.signal(signal.SIGTERM, prev_sigterm)
|
||
signal.signal(signal.SIGINT, prev_sigint)
|
||
_restore_env()
|
||
|
||
|
||
def _run_configure_databricks() -> None:
|
||
"""
|
||
Configure coding harnesses to use Databricks Unity AI Gateway.
|
||
|
||
Shells out to ``ucode configure`` to authenticate workspaces and set
|
||
up harnesses (Claude SDK, Codex, OpenAI Agents, Pi). After setup,
|
||
Omnigent reads ``~/.ucode/state.json`` to pick per-harness model
|
||
defaults and base URLs.
|
||
|
||
:returns: None.
|
||
:raises click.ClickException: If ucode command resolution,
|
||
configuration, or state verification fails.
|
||
"""
|
||
ucode_command = find_ucode_command()
|
||
# ucode only configures the model-serving gateway, so it gets the
|
||
# gateway workspace(s) only — not the MCP-only profiles, which are
|
||
# authenticated during profile onboarding and have no ucode role.
|
||
workspace_urls = model_gateway_workspace_urls()
|
||
click.echo("Running `ucode configure --workspaces ...`...")
|
||
|
||
result = subprocess.run(
|
||
build_ucode_configure_command(ucode_command, workspace_urls=workspace_urls),
|
||
check=False,
|
||
)
|
||
if result.returncode != 0:
|
||
raise click.ClickException(
|
||
f"`ucode configure` exited with code {result.returncode}; "
|
||
"see the command output above for details."
|
||
)
|
||
|
||
click.echo("ucode configuration complete. Omnigent will use state.json for harness setup.")
|
||
|
||
|
||
def _warn_missing_harness_dependencies() -> None:
|
||
"""
|
||
Warn about external (non-Python) tools the coding harnesses need.
|
||
|
||
Surfaces every missing/outdated dependency up front (when the user
|
||
opens ``configure harnesses``) so a fresh machine learns about all of
|
||
them at once, rather than discovering each at the moment a harness or
|
||
wrapper needs it (Node when a harness CLI runs, tmux when ``omnigent
|
||
claude`` launches). This *warns* rather than aborts on purpose: the
|
||
pure-Python ``openai-agents`` harness runs without either tool, so a
|
||
hard failure would block a valid flow — but ``omnigent claude`` /
|
||
``codex`` do need both, hence the prominent notice.
|
||
|
||
:returns: None. Side effect: writes a yellow warning block to stderr
|
||
via :mod:`omnigent.inner.ui` when one or more dependencies are
|
||
missing.
|
||
"""
|
||
problems: list[str] = []
|
||
node_problem = _node_dependency_problem()
|
||
if node_problem is not None:
|
||
problems.append(node_problem)
|
||
if shutil.which("tmux") is None:
|
||
problems.append(
|
||
"tmux not found — native Claude/Codex need tmux (macOS: `brew install tmux`)."
|
||
)
|
||
if not problems:
|
||
return
|
||
ui.warn("Some harnesses need external tools:")
|
||
for problem in problems:
|
||
ui.err_console.print(f" • {problem}", style="omni.warning", markup=False)
|
||
ui.err_console.print(
|
||
"You can configure credentials now; install these before launching those harnesses.",
|
||
style="omni.warning",
|
||
markup=False,
|
||
)
|
||
|
||
|
||
def _print_credentials_by_harness() -> None:
|
||
"""Print configured model credentials grouped by harness (the ``config list`` view).
|
||
|
||
Renders the effective config **merged with ambient detections** (a
|
||
detected env key / CLI login shows as an ordinary credential, with no
|
||
separate "detected vs configured" split) grouped under each harness
|
||
family, with the per-family default marked — via
|
||
:func:`render_provider_listing_by_harness`.
|
||
|
||
:returns: None. Side effect: writes the listing to the onboarding
|
||
console.
|
||
"""
|
||
from omnigent.onboarding.configure_models import render_provider_listing_by_harness
|
||
from omnigent.onboarding.detected import effective_config_with_detected
|
||
from omnigent.onboarding.provider_config import load_providers
|
||
|
||
config = effective_config_with_detected(_load_effective_config())
|
||
providers = load_providers(config)
|
||
render_provider_listing_by_harness(config, providers)
|
||
|
||
|
||
def _existing_key_name_for_ref( # type: ignore[explicit-any] # config is a yaml-boundary mapping
|
||
config: dict[str, Any],
|
||
family: str,
|
||
api_key_ref: str,
|
||
) -> str | None:
|
||
"""Return the name of a ``key`` provider on *family* using *api_key_ref*.
|
||
|
||
Two API keys are "the same key" when they read the same secret source
|
||
(the same ``env:`` / ``keychain:`` reference). The add flow uses this to
|
||
update such a key in place rather than writing a second, identical entry —
|
||
so re-adding a key you already have stays idempotent, while a key from a
|
||
genuinely different source gets its own entry (the "keep both" behavior).
|
||
|
||
:param config: The parsed global config mapping (``providers:`` block).
|
||
:param family: The harness family the key serves, ``"anthropic"`` or
|
||
``"openai"``.
|
||
:param api_key_ref: The secret reference to match, e.g.
|
||
``"env:ANTHROPIC_API_KEY"`` or ``"keychain:anthropic"``.
|
||
:returns: The provider name whose *family* block references the same
|
||
secret, e.g. ``"anthropic"``, or ``None`` when no such key exists.
|
||
"""
|
||
from omnigent.onboarding.provider_config import KEY_KIND, load_providers
|
||
|
||
for name, entry in load_providers(config).items():
|
||
if entry.kind != KEY_KIND:
|
||
continue
|
||
fam = entry.families.get(family)
|
||
if fam is not None and fam.api_key_ref == api_key_ref:
|
||
return name
|
||
return None
|
||
|
||
|
||
def _unique_provider_name( # type: ignore[explicit-any] # config is a yaml-boundary mapping
|
||
config: dict[str, Any],
|
||
candidate: str,
|
||
) -> str:
|
||
"""Return *candidate*, suffixed numerically until it's a free provider name.
|
||
|
||
Provider names key the ``providers:`` mapping, so a colliding name would
|
||
overwrite an existing entry on deep-merge. When the add flow keeps a
|
||
second credential (an API key from a new source for a vendor that already
|
||
has one), this derives a fresh name — ``anthropic`` → ``anthropic-2`` →
|
||
``anthropic-3`` — so both coexist.
|
||
|
||
:param config: The parsed global config mapping (``providers:`` block).
|
||
:param candidate: The preferred name, e.g. ``"anthropic"``.
|
||
:returns: *candidate* if unused, else the first free ``<candidate>-<n>``
|
||
(``n`` starting at 2), e.g. ``"anthropic-2"``.
|
||
"""
|
||
from omnigent.onboarding.provider_config import load_providers
|
||
|
||
existing = set(load_providers(config))
|
||
if candidate not in existing:
|
||
return candidate
|
||
n = 2
|
||
while f"{candidate}-{n}" in existing:
|
||
n += 1
|
||
return f"{candidate}-{n}"
|
||
|
||
|
||
def _resolve_key_provider_name( # type: ignore[explicit-any] # config is a yaml-boundary mapping
|
||
config: dict[str, Any],
|
||
family: str,
|
||
candidate: str,
|
||
api_key_ref: str,
|
||
) -> str:
|
||
"""Pick the entry name for an API key being added — update vs keep-both.
|
||
|
||
Realizes the "allow multiple API keys, keep both if source differs"
|
||
behavior: a key whose secret source (*api_key_ref*) matches an existing
|
||
key on *family* reuses that entry's name (an in-place update of the same
|
||
credential); a key from a new source takes a fresh, unique name so it
|
||
coexists with the others.
|
||
|
||
:param config: The parsed global config mapping (``providers:`` block).
|
||
:param family: The harness family the key serves, ``"anthropic"`` or
|
||
``"openai"``.
|
||
:param candidate: The preferred name (the vendor id for a preset, or the
|
||
user-typed name for "Other provider"), e.g. ``"anthropic"``.
|
||
:param api_key_ref: The key's secret reference, e.g.
|
||
``"env:ANTHROPIC_API_KEY"`` or ``"keychain:anthropic"``.
|
||
:returns: The existing same-source entry's name (update in place), else a
|
||
unique name derived from *candidate* (keep both), e.g.
|
||
``"anthropic-2"``.
|
||
"""
|
||
same_source = _existing_key_name_for_ref(config, family, api_key_ref)
|
||
if same_source is not None:
|
||
return same_source
|
||
return _unique_provider_name(config, candidate)
|
||
|
||
|
||
def _credential_source_hint(entry: ProviderEntry, family: str) -> str | None:
|
||
"""A short, non-secret descriptor of where a key's secret comes from.
|
||
|
||
Used to disambiguate two API keys that would otherwise share a label
|
||
(e.g. two "Anthropic API Key" rows): an ``env:`` ref renders as
|
||
``$VAR``, a ``keychain:`` ref as its stored name, an inline ``$VAR`` as
|
||
itself. Only meaningful for credential kinds that carry an inline family
|
||
block (``key`` / ``gateway`` / ``local``).
|
||
|
||
:param entry: The parsed provider entry.
|
||
:param family: The surface whose secret source to describe,
|
||
``"anthropic"``, ``"openai"``, or ``"pi"``.
|
||
:returns: A display hint such as ``"$ANTHROPIC_API_KEY"`` or
|
||
``"anthropic-2"``, or ``None`` when the family has no resolvable
|
||
source descriptor.
|
||
"""
|
||
from omnigent.onboarding.provider_config import (
|
||
ANTHROPIC_FAMILY,
|
||
OPENAI_FAMILY,
|
||
PI_SURFACE,
|
||
)
|
||
|
||
raw = entry.families.get(family)
|
||
if raw is None and family == PI_SURFACE:
|
||
# The pi surface carries no family block of its own — pi consumes
|
||
# the credential of whichever family it routes through (anthropic
|
||
# preferred), so describe that family's source instead.
|
||
for fam in (ANTHROPIC_FAMILY, OPENAI_FAMILY):
|
||
raw = entry.families.get(fam)
|
||
if raw is not None:
|
||
break
|
||
if raw is None:
|
||
return None
|
||
if raw.api_key_ref is not None:
|
||
if raw.api_key_ref.startswith("env:"):
|
||
return f"${raw.api_key_ref[len('env:') :]}"
|
||
if raw.api_key_ref.startswith("keychain:"):
|
||
return raw.api_key_ref[len("keychain:") :]
|
||
if raw.api_key is not None and raw.api_key.startswith("$"):
|
||
return raw.api_key
|
||
return None
|
||
|
||
|
||
def _family_key_count( # type: ignore[explicit-any] # config is a yaml-boundary mapping
|
||
config: dict[str, Any],
|
||
family: str,
|
||
) -> int:
|
||
"""Count the ``key`` providers serving *family*.
|
||
|
||
The ``($VAR)`` disambiguation hint is shown only when more than one API
|
||
key serves a harness — a lone key needs no source qualifier.
|
||
|
||
:param config: The parsed global config mapping (``providers:`` block).
|
||
:param family: The harness family, ``"anthropic"`` or ``"openai"``.
|
||
:returns: The number of ``kind: key`` providers serving *family*.
|
||
"""
|
||
from omnigent.onboarding.provider_config import (
|
||
KEY_KIND,
|
||
load_providers,
|
||
provider_families,
|
||
)
|
||
|
||
return sum(
|
||
1
|
||
for entry in load_providers(config).values()
|
||
if entry.kind == KEY_KIND and family in provider_families(entry)
|
||
)
|
||
|
||
|
||
def _family_credential_label( # type: ignore[explicit-any] # config is a yaml-boundary mapping
|
||
config: dict[str, Any],
|
||
family: str,
|
||
name: str,
|
||
entry: ProviderEntry,
|
||
) -> str:
|
||
"""A credential label, qualified with its source when keys would collide.
|
||
|
||
Wraps :func:`_credential_label`, appending the ``($VAR)`` source hint for
|
||
a ``key`` provider when more than one API key serves *family* (so two
|
||
"Anthropic API Key" rows read as distinct). Non-key kinds and the
|
||
single-key case render the plain label.
|
||
|
||
:param config: The parsed global config mapping (``providers:`` block).
|
||
:param family: The harness family in context, ``"anthropic"`` /
|
||
``"openai"``.
|
||
:param name: The provider id keyed under ``providers:``, e.g.
|
||
``"anthropic-2"``.
|
||
:param entry: The parsed provider entry.
|
||
:returns: A human label, e.g. ``"Anthropic API Key ($ANTHROPIC_API_KEY)"``
|
||
when disambiguation applies, else ``"Anthropic API Key"``.
|
||
"""
|
||
from omnigent.onboarding.provider_config import KEY_KIND
|
||
|
||
base = _credential_label(name, entry)
|
||
if entry.kind != KEY_KIND or _family_key_count(config, family) <= 1:
|
||
return base
|
||
hint = _credential_source_hint(entry, family)
|
||
return f"{base} ({hint})" if hint else base
|
||
|
||
|
||
def _configure_harness_add(family: str | None = None) -> str | None:
|
||
"""Run the interactive ``add a provider`` flow and persist the entry.
|
||
|
||
Prompts for the provider kind (key / subscription / gateway /
|
||
databricks), gathers the kind-specific fields, deep-merges the single
|
||
entry under ``providers:`` (an add never rewrites siblings), and makes
|
||
it the default for any family it serves that has **no** default yet
|
||
(so a first provider just works; an existing default is left for the
|
||
user to change by selecting it in the harness tree).
|
||
|
||
:param family: When set (``"anthropic"`` / ``"openai"`` / ``"pi"``),
|
||
the add menu is scoped to credentials that can drive that harness —
|
||
the per-harness "Add a provider" path. ``None`` shows the full menu.
|
||
:returns: A confirmation message for the caller to show as a transient
|
||
status. Side effect: writes to ``~/.omnigent/config.yaml`` and,
|
||
for a pasted API key, the secret store.
|
||
"""
|
||
from omnigent.onboarding import secrets as secret_store
|
||
from omnigent.onboarding.ambient import detect_providers
|
||
from omnigent.onboarding.configure_models import (
|
||
AddOption,
|
||
add_menu_options,
|
||
add_menu_options_for_family,
|
||
build_bedrock_provider_entry,
|
||
build_cli_config_provider_entry,
|
||
build_databricks_provider_entry,
|
||
build_gateway_provider_entry,
|
||
build_key_provider_entry,
|
||
build_subscription_provider_entry,
|
||
default_base_url_for_family,
|
||
family_for_key_provider,
|
||
key_provider_endpoint,
|
||
other_key_providers,
|
||
provider_display_name,
|
||
)
|
||
from omnigent.onboarding.interactive import console, prompt_text, select
|
||
from omnigent.onboarding.provider_config import (
|
||
ANTHROPIC_FAMILY,
|
||
BEDROCK_KIND,
|
||
CHAT_WIRE_API,
|
||
CLI_CONFIG_KIND,
|
||
DATABRICKS_KIND,
|
||
OPENAI_FAMILY,
|
||
PI_SURFACE,
|
||
RESPONSES_WIRE_API,
|
||
SUBSCRIPTION_KIND,
|
||
load_providers,
|
||
provider_entry_settings,
|
||
set_default_provider,
|
||
)
|
||
|
||
# The ucode agent that backs each harness surface's model serving. When the
|
||
# user adds Databricks from a specific harness page, we configure ucode for
|
||
# ONLY that harness (not all of claude/codex/pi) so ucode touches just the
|
||
# one tool the user is wiring up.
|
||
_FAMILY_UCODE_AGENT = {ANTHROPIC_FAMILY: "claude", OPENAI_FAMILY: "codex", PI_SURFACE: "pi"}
|
||
|
||
# A flat, credential-aware menu: the user picks "OpenAI — API key" or
|
||
# "Claude — subscription" directly (rather than a bare kind then
|
||
# provider two-step). Each option carries the resolved kind and, for
|
||
# the common cases, a preset provider/cli. When entered from a specific
|
||
# harness, the menu is scoped to that harness's surface.
|
||
options = add_menu_options_for_family(family) if family is not None else add_menu_options()
|
||
# A custom provider defined by the user's own ~/.codex/config.toml
|
||
# (e.g. isaac's Databricks AI Gateway) that is not currently configured
|
||
# gets its own add option. This is the only way back after Remove —
|
||
# removal dismisses the detection so it stops auto-adopting, and there
|
||
# is nothing to type/paste here (the credential lives in that file).
|
||
cli_config_dets: list[DetectedProvider] = []
|
||
if family in (None, OPENAI_FAMILY):
|
||
configured_names = set(load_providers(_load_global_config()))
|
||
cli_config_dets = [
|
||
d
|
||
for d in detect_providers()
|
||
if d.kind == CLI_CONFIG_KIND and d.name not in configured_names
|
||
]
|
||
# Base options first, then one row per detected config provider — the
|
||
# selection index maps back into cli_config_dets below.
|
||
base_option_count = len(options)
|
||
options = options + [
|
||
AddOption(
|
||
label=f"\N{GEAR}\N{VARIATION SELECTOR-16} {d.display_name or d.name} — "
|
||
"from your Codex config",
|
||
description=(
|
||
f"Use the {str(d.model_provider)!r} provider your ~/.codex/config.toml "
|
||
"defines and authenticates."
|
||
),
|
||
kind=CLI_CONFIG_KIND,
|
||
)
|
||
for d in cli_config_dets
|
||
]
|
||
choice = select(
|
||
"What do you want to add?",
|
||
[o.label for o in options],
|
||
descriptions=[o.description for o in options],
|
||
clear_on_exit=True,
|
||
)
|
||
if choice < 0: # Esc — abort the add
|
||
return None
|
||
chosen = options[choice]
|
||
kind = chosen.kind
|
||
|
||
name: str
|
||
# Any (not object): this entry is handed to provider_entry_settings /
|
||
# set_default_provider, which type their config mappings as object;
|
||
# _ConfigValue would trip dict invariance against those. Matches the
|
||
# cli.py yaml-boundary convention.
|
||
entry: dict[str, Any] # type: ignore[explicit-any]
|
||
|
||
if kind == CLI_CONFIG_KIND:
|
||
# One detected-config row was appended per cli_config_dets entry, in
|
||
# order, after the base options — map the selection back to its
|
||
# detection. Nothing to prompt for: the provider definition AND its
|
||
# credential live in ~/.codex/config.toml; the entry only pins it.
|
||
det = cli_config_dets[choice - base_option_count]
|
||
if det.model_provider is None: # always set on cli-config detections
|
||
raise click.ClickException("internal: cli-config detection missing model_provider")
|
||
name = det.name
|
||
entry = build_cli_config_provider_entry("codex", det.model_provider, det.display_name)
|
||
# Re-adding is the user saying "I want this auto-detected credential
|
||
# after all" — drop any standing dismissal so it behaves like an
|
||
# ordinary detection again (e.g. re-adopts after a config self-heal).
|
||
_clear_detection_dismissal(name)
|
||
|
||
elif kind == "key":
|
||
if chosen.provider is not None:
|
||
provider = chosen.provider # preset by the flat option (OpenAI/Anthropic/OpenRouter)
|
||
# Preset: the preferred name is the provider id — but the final name
|
||
# is resolved from the key's source below (update in place vs keep
|
||
# both), so a second key for the same vendor doesn't overwrite the
|
||
# first.
|
||
candidate = provider
|
||
else:
|
||
# "Other provider — API key": pick from the remaining catalog,
|
||
# shown by friendly display name. This is the one key case where a
|
||
# custom name is useful (e.g. two configs for the same vendor), so
|
||
# it's the only non-gateway path that still prompts for a name.
|
||
others = other_key_providers()
|
||
if not others: # ponytail: every catalog key-provider is already a preset/configured
|
||
click.echo("No other API-key providers left to add.")
|
||
return None
|
||
_other_choice = select(
|
||
"Which provider?",
|
||
[provider_display_name(p) for p in others],
|
||
clear_on_exit=True,
|
||
)
|
||
if _other_choice < 0: # Esc — abort the add
|
||
return None
|
||
provider = others[_other_choice]
|
||
candidate = prompt_text("Name for this provider", default=provider)
|
||
disp = provider_display_name(provider)
|
||
family = family_for_key_provider(provider)
|
||
# The entry name is resolved from the key's source (not just the
|
||
# candidate): a key whose source matches an existing one updates it in
|
||
# place, while a key from a new source takes a fresh name so both
|
||
# coexist ("allow multiple API keys"). See _resolve_key_provider_name.
|
||
config_now = _load_global_config()
|
||
# Offer to reuse a detected env var for this provider rather than
|
||
# forcing the user to re-paste a key they already have in the env.
|
||
detected = {d.name: d for d in detect_providers()}
|
||
api_key_ref: str
|
||
if (
|
||
provider in detected
|
||
and detected[provider].kind == "key"
|
||
and click.confirm(
|
||
f"Detected {detected[provider].source} in the environment — use it?",
|
||
default=True,
|
||
)
|
||
):
|
||
env_var = detected[provider].source.lstrip("$") # e.g. "ANTHROPIC_API_KEY"
|
||
api_key_ref = f"env:{env_var}"
|
||
name = _resolve_key_provider_name(config_now, family, candidate, api_key_ref)
|
||
else:
|
||
# A pasted key is stored at keychain:<name>; resolve the name first
|
||
# (an existing key in this same keychain slot is replaced in place,
|
||
# otherwise we pick a free name) so we store under and reference the
|
||
# final name.
|
||
name = _resolve_key_provider_name(
|
||
config_now, family, candidate, f"keychain:{candidate}"
|
||
)
|
||
pasted = prompt_text(f"{disp} API key", hide_input=True)
|
||
secret_store.store_secret(name, pasted)
|
||
api_key_ref = f"keychain:{name}"
|
||
|
||
# Default model — free-form text entry. The bundled catalog lags new
|
||
# releases (e.g. a brand-new claude-sonnet-4-6 won't be listed yet), so
|
||
# a fixed picker would block the user from a model they can actually
|
||
# use. Pre-fill the canonical default and let the user type ANY model
|
||
# id. Blank → the default (or no pin when unknown). Always persisting
|
||
# a pin keeps a later re-add from silently dropping ``models.default``.
|
||
from omnigent.onboarding.providers import default_chat_model
|
||
|
||
catalog_default = default_chat_model(provider)
|
||
# default=catalog_default (str | None): a known provider pre-fills its
|
||
# default (blank-enter accepts it); an unknown provider has no default,
|
||
# so the user types a model id. ``.strip() or None`` keeps an
|
||
# all-whitespace entry from becoming a bogus pin.
|
||
typed = prompt_text("Default model", default=catalog_default)
|
||
default_model = typed.strip() or None
|
||
|
||
# A third-party OpenAI-compatible vendor (OpenRouter, Groq, …) is
|
||
# reached at its OWN base_url and speaks Chat Completions; openai /
|
||
# anthropic use the canonical family endpoint (and openai keeps the
|
||
# Responses default). Using the family default for a vendor sent its
|
||
# traffic to api.openai.com — the reason an OpenRouter key failed.
|
||
endpoint = key_provider_endpoint(provider)
|
||
if endpoint is not None:
|
||
base_url = endpoint.base_url
|
||
key_wire_api: str | None = endpoint.wire_api
|
||
else:
|
||
base_url = default_base_url_for_family(family)
|
||
key_wire_api = None
|
||
entry = build_key_provider_entry(
|
||
family=family,
|
||
base_url=base_url,
|
||
api_key_ref=api_key_ref,
|
||
default_model=default_model,
|
||
wire_api=key_wire_api,
|
||
)
|
||
|
||
elif kind == "subscription":
|
||
cli_name = chosen.cli # preset by the flat option (claude / codex)
|
||
if cli_name is None:
|
||
raise click.ClickException("internal: subscription option missing a cli login")
|
||
from omnigent.onboarding.harness_install import harness_install_spec, harness_login
|
||
|
||
login_family = {agent: fam for fam, agent in _FAMILY_UCODE_AGENT.items()}.get(cli_name)
|
||
if login_family is None:
|
||
raise click.ClickException(f"internal: no login family for cli {cli_name!r}")
|
||
spec = harness_install_spec(login_family)
|
||
disp = spec.display if spec is not None else cli_name
|
||
# A harness has at most ONE subscription — the CLI's own login. If one
|
||
# is already configured for this CLI (under any name, including an
|
||
# ambient login adopted as e.g. ``claude``), adding another just
|
||
# duplicates it — the ``claude`` + ``claude-subscription`` bug. Offer to
|
||
# replace the existing one; declining aborts before we touch the login.
|
||
existing_subs = [
|
||
n
|
||
for n, e in load_providers(_load_global_config()).items()
|
||
if e.kind == SUBSCRIPTION_KIND and e.cli == cli_name
|
||
]
|
||
if existing_subs:
|
||
brand = _CLI_LOGIN_BRAND.get(cli_name, cli_name)
|
||
replace = select(
|
||
f"A {brand} subscription is already configured. Replace it?",
|
||
["Replace it", "Keep the current one"],
|
||
default=0,
|
||
clear_on_exit=True,
|
||
)
|
||
if replace != 0: # "Keep the current one" or Esc — abort the add
|
||
return None
|
||
# Configure is the single place to sign in: drive the harness's own
|
||
# login (a no-op if already logged in). Only record the subscription
|
||
# once the CLI is actually authenticated — otherwise we'd persist a
|
||
# phantom subscription that strands the user at the harness's own login
|
||
# screen at run time (the exact bug this whole flow fixes).
|
||
console.print(f" [dim]Signing in to {disp} (its login will open)…[/dim]")
|
||
if not harness_login(login_family):
|
||
return f"✗ {disp} login not completed — subscription not added"
|
||
# Login succeeded — drop the existing subscription(s) for this CLI so the
|
||
# canonical entry is the only one left (clearing the old default lets the
|
||
# new entry re-claim the family default below). Done AFTER login so a
|
||
# failed login leaves the existing subscription intact.
|
||
if existing_subs:
|
||
block = _load_global_config().get("providers")
|
||
if isinstance(block, dict):
|
||
remaining = {k: v for k, v in block.items() if k not in existing_subs}
|
||
_save_global_config({"providers": remaining}) # wholesale replace
|
||
# Subscription name is derived from the CLI login — no prompt.
|
||
name = f"{cli_name}-subscription"
|
||
entry = build_subscription_provider_entry(cli_name)
|
||
|
||
elif kind == "gateway":
|
||
name = prompt_text("Name for this gateway", default="gateway")
|
||
base_url = prompt_text("Gateway base_url (OpenAI/Anthropic-compatible)")
|
||
pasted = prompt_text("Gateway API key", hide_input=True)
|
||
secret_store.store_secret(name, pasted)
|
||
# Which harness surfaces — one clear pick instead of two y/n prompts.
|
||
# (These are *harness* surfaces: Codex/OpenAI → codex + openai-agents;
|
||
# Claude/Anthropic → claude-sdk + native-claude.)
|
||
surface_choice = select(
|
||
"Which harnesses can this gateway drive?",
|
||
[
|
||
"Both Claude and Codex",
|
||
"Codex / OpenAI only (codex, openai-agents)",
|
||
"Claude only (claude-sdk, native-claude)",
|
||
],
|
||
default=0,
|
||
clear_on_exit=True,
|
||
)
|
||
if surface_choice < 0: # Esc — abort the add
|
||
return None
|
||
families = (
|
||
[OPENAI_FAMILY, ANTHROPIC_FAMILY]
|
||
if surface_choice == 0
|
||
else [OPENAI_FAMILY]
|
||
if surface_choice == 1
|
||
else [ANTHROPIC_FAMILY]
|
||
)
|
||
# Wire protocol for the OpenAI surface: OpenAI / LiteLLM speak the
|
||
# Responses API; OpenRouter and many OSS-model gateways are
|
||
# Chat-Completions-only. Picking wrong makes every turn fail (the
|
||
# exact "OpenRouter doesn't work but LiteLLM does" symptom), so ask —
|
||
# defaulting to Chat when the URL looks like OpenRouter.
|
||
wire_api: str | None = None
|
||
if OPENAI_FAMILY in families:
|
||
wire_choice = select(
|
||
"OpenAI wire protocol for this gateway?",
|
||
[
|
||
"Responses API (OpenAI, LiteLLM)",
|
||
"Chat Completions (OpenRouter, most OSS-model gateways)",
|
||
],
|
||
default=1 if "openrouter" in base_url.lower() else 0,
|
||
clear_on_exit=True,
|
||
)
|
||
if wire_choice < 0: # Esc — abort the add
|
||
return None
|
||
wire_api = RESPONSES_WIRE_API if wire_choice == 0 else CHAT_WIRE_API
|
||
# Default model per served surface. A gateway has NO catalog default,
|
||
# so without a pin routing would fall back to a vendor model the
|
||
# gateway can't serve. The OpenAI surface pre-fills a broadly-served
|
||
# OSS default (moonshotai/kimi-k2.6, via the openrouter pin); the
|
||
# user can type any gateway model id.
|
||
from omnigent.onboarding.providers import default_chat_model
|
||
|
||
models: dict[str, str] = {}
|
||
if OPENAI_FAMILY in families:
|
||
models[OPENAI_FAMILY] = prompt_text(
|
||
"Default model for the Codex / OpenAI surface",
|
||
default=default_chat_model("openrouter"),
|
||
).strip()
|
||
if ANTHROPIC_FAMILY in families:
|
||
models[ANTHROPIC_FAMILY] = prompt_text(
|
||
"Default model for the Claude surface (the gateway's Claude model id)"
|
||
).strip()
|
||
entry = build_gateway_provider_entry(
|
||
base_url=base_url,
|
||
api_key_ref=f"keychain:{name}",
|
||
families=families,
|
||
wire_api=wire_api,
|
||
models=models,
|
||
)
|
||
|
||
elif kind == BEDROCK_KIND:
|
||
# Bedrock drives the native Claude terminal in AWS Bedrock mode. It
|
||
# authenticates from AWS_BEARER_TOKEN_BEDROCK in the env at launch
|
||
# (Claude Code ignores apiKeyHelper once Bedrock mode is on), so offer
|
||
# to reference an exported token, else store a pasted one in the keychain.
|
||
name = prompt_text("Name for this Bedrock provider", default="bedrock")
|
||
base_url = prompt_text(
|
||
"Bedrock base_url (regional runtime endpoint, or your Bedrock-compatible gateway)",
|
||
default="https://bedrock-runtime.us-east-1.amazonaws.com",
|
||
)
|
||
if os.environ.get("AWS_BEARER_TOKEN_BEDROCK") and click.confirm(
|
||
"Detected AWS_BEARER_TOKEN_BEDROCK in the environment — use it?", default=True
|
||
):
|
||
api_key_ref = "env:AWS_BEARER_TOKEN_BEDROCK"
|
||
else:
|
||
pasted = prompt_text("Amazon Bedrock API key (bearer token)", hide_input=True)
|
||
secret_store.store_secret(name, pasted)
|
||
api_key_ref = f"keychain:{name}"
|
||
# Bedrock has no catalog default and Claude's own default model is
|
||
# usually not enabled on a Bedrock account, so pin an explicit id.
|
||
default_model = (
|
||
prompt_text(
|
||
"Default model (Bedrock inference-profile id, e.g. "
|
||
"us.anthropic.claude-opus-4-5-20251101-v1:0)"
|
||
).strip()
|
||
or None
|
||
)
|
||
family = ANTHROPIC_FAMILY
|
||
entry = build_bedrock_provider_entry(
|
||
base_url=base_url,
|
||
api_key_ref=api_key_ref,
|
||
default_model=default_model,
|
||
)
|
||
|
||
else: # databricks
|
||
# Gate on the `databricks` extra: a `kind: databricks` provider mints
|
||
# workspace OAuth tokens via databricks-sdk at runtime
|
||
# (omnigent/runtime/credentials/databricks.py), and the SDK is no
|
||
# longer a default dependency. Abort before any side effect (the
|
||
# `databricks auth login` browser flow, `ucode configure`) so the
|
||
# user isn't signed into a workspace that routing then can't use.
|
||
from omnigent.onboarding.databricks_config import (
|
||
DATABRICKS_EXTRA_INSTALL_HINT,
|
||
databricks_sdk_installed,
|
||
)
|
||
|
||
if not databricks_sdk_installed():
|
||
from rich.markup import escape as _rich_escape
|
||
|
||
# The status renders through Text.from_markup, where the literal
|
||
# `[databricks]` in the install command would parse as a tag.
|
||
return (
|
||
"✗ Databricks routing needs the databricks extra — "
|
||
f"{_rich_escape(DATABRICKS_EXTRA_INSTALL_HINT)}"
|
||
)
|
||
|
||
# The intro + URL prompt render inline, exactly like every other add
|
||
# flow (the add-menu picker already erased its own frame on exit via
|
||
# `clear_on_exit`) — entering the Databricks option should NOT blank the
|
||
# whole screen. The one clear we keep is *after* the subprocess (below):
|
||
# `databricks auth login` + `ucode configure` print a lot, and the
|
||
# in-place menu redraw we return to can only erase its own frame, so we
|
||
# wipe that leftover output once the login finishes.
|
||
# Ask only for the workspace URL — never a profile name. The flow
|
||
# below authenticates that one workspace and runs `ucode configure`
|
||
# against it, scoped to the harness the user drilled into. This is
|
||
# the one place Omnigent triggers a Databricks CLI / ucode login;
|
||
# it never happens on a bare `run`, so a user who only wants their
|
||
# own provider is never routed through Databricks unexpectedly.
|
||
from omnigent.onboarding.configure_models import family_label
|
||
from omnigent.onboarding.databricks_config import normalize_workspace_url
|
||
from omnigent.onboarding.interactive import clear_screen
|
||
from omnigent.onboarding.setup import login_databricks_workspace
|
||
from omnigent.onboarding.ucode_setup import (
|
||
configure_ucode_for_workspace,
|
||
ucode_workspace_exists,
|
||
)
|
||
|
||
_routed = f"{family_label(family)}'s" if family is not None else "your harnesses'"
|
||
console.print(
|
||
f" [dim]Routes {_routed} model calls through this workspace's "
|
||
"Databricks Unity AI Gateway (via ucode), so usage is governed and "
|
||
"billed there. This signs you into the workspace and runs "
|
||
"`ucode configure` for it.[/dim]"
|
||
)
|
||
workspace_url = prompt_text(
|
||
"Databricks workspace URL (e.g. https://example.cloud.databricks.com)"
|
||
).strip()
|
||
if not workspace_url: # blank — abort the add
|
||
return None
|
||
if not workspace_url.startswith(("http://", "https://")):
|
||
workspace_url = f"https://{workspace_url}"
|
||
# Reduce to scheme://host. Users paste the URL from a browser address
|
||
# bar, whose `/browse?o=...` path breaks both the saved profile host
|
||
# and `ucode configure` (the Databricks CLI keys OAuth tokens by host,
|
||
# so a path-laden value yields "no access token").
|
||
normalized_workspace_url = normalize_workspace_url(workspace_url)
|
||
if normalized_workspace_url != workspace_url.rstrip("/"):
|
||
console.print(
|
||
f" [dim]Using {normalized_workspace_url} — ignored the extra "
|
||
"path from the pasted URL.[/dim]"
|
||
)
|
||
workspace_url = normalized_workspace_url
|
||
|
||
# 1. Authenticate the workspace (returns the ~/.databrickscfg profile
|
||
# name) and 2. run `ucode configure` against it for model serving —
|
||
# scoped to the harness the user drilled into (or both when added
|
||
# from the un-scoped menu), so ucode configures only what's needed.
|
||
if family is not None:
|
||
ucode_agents = [_FAMILY_UCODE_AGENT[family]]
|
||
else:
|
||
ucode_agents = sorted(_FAMILY_UCODE_AGENT.values())
|
||
profile = login_databricks_workspace(workspace_url, console=console)
|
||
configure_ucode_for_workspace(workspace_url, agents=ucode_agents)
|
||
# Fail loud if ucode didn't actually record state for the workspace —
|
||
# otherwise routing would silently fall back and confuse the user.
|
||
if not ucode_workspace_exists(workspace_url):
|
||
raise click.ClickException(
|
||
f"`ucode configure` finished but recorded no state for {workspace_url}. "
|
||
"Re-run and check the ucode output above."
|
||
)
|
||
# Wipe the verbose login + ucode output so the menu we return to (with a
|
||
# "✓ Added databricks" status) renders on a clean screen.
|
||
clear_screen()
|
||
# Databricks name is fixed — no prompt. The provider keys on the
|
||
# profile; runtime resolves profile → workspace URL → ucode state.
|
||
name = "databricks"
|
||
entry = build_databricks_provider_entry(profile)
|
||
|
||
from omnigent.onboarding.configure_models import family_label
|
||
from omnigent.onboarding.provider_config import (
|
||
provider_families,
|
||
surface_default_provider,
|
||
)
|
||
|
||
# Persist the entry (deep-merge — doesn't disturb sibling entries).
|
||
_save_global_config(
|
||
provider_entry_settings(name, entry, make_default=False),
|
||
deep_merge_keys=("providers",),
|
||
)
|
||
# Become the default for any surface it serves that has NO default yet,
|
||
# so a first provider "just works". An existing default is left alone —
|
||
# the user changes defaults by selecting a provider in the harness tree
|
||
# (per-surface, so a shared provider can default one harness, not both).
|
||
# The pi surface checks its *effective* default: a family default already
|
||
# drives pi via the fallback, so claiming the explicit pi scope then
|
||
# would silently re-route pi away from it.
|
||
parsed = load_providers({"providers": {name: entry}})[name]
|
||
# Databricks routing is configured in ucode PER HARNESS (we only ran
|
||
# `ucode configure` for the surface the user drilled into), so it must only
|
||
# become the default for THAT surface — defaulting the other harnesses too
|
||
# would route them through a workspace ucode never configured for them.
|
||
# Other kinds (a gateway serving both families with one base_url + key)
|
||
# still default every surface they serve.
|
||
if entry["kind"] == DATABRICKS_KIND and family is not None:
|
||
default_families = [family]
|
||
else:
|
||
default_families = sorted(provider_families(parsed))
|
||
became_default: list[str] = []
|
||
for fam in default_families:
|
||
cfg = _load_global_config()
|
||
if surface_default_provider(cfg, fam) is not None:
|
||
continue
|
||
block = cfg.get("providers")
|
||
if isinstance(block, dict):
|
||
_save_global_config({"providers": set_default_provider(block, name, fam)})
|
||
became_default.append(fam)
|
||
if became_default:
|
||
labels = " · ".join(family_label(f) for f in became_default)
|
||
return f"✓ Added {name} — default for {labels}"
|
||
return f"✓ Added {name}"
|
||
|
||
|
||
def _adopt_detected_providers() -> list[str]:
|
||
"""Persist ambient-detected providers into the config, returning new names.
|
||
|
||
Opening ``configure harnesses`` adopts any detected credential (env key,
|
||
CLI login, local Ollama) not already in ``providers:`` as a real,
|
||
editable entry — so the tree shows one uniform provider list with no
|
||
"detected vs configured" split. Writes the merged view (explicit +
|
||
detected, with detected auto-defaulting per family) wholesale, and only
|
||
when there is something new to adopt (idempotent on re-open).
|
||
|
||
:returns: The names adopted this call, e.g. ``["anthropic", "codex"]``;
|
||
empty when every detection is already configured.
|
||
"""
|
||
from omnigent.onboarding.detected import (
|
||
effective_config_with_detected,
|
||
providers_to_adopt,
|
||
)
|
||
|
||
config = _load_global_config()
|
||
to_adopt = providers_to_adopt(config)
|
||
if not to_adopt:
|
||
return []
|
||
merged = effective_config_with_detected(config)
|
||
_save_global_config({"providers": merged["providers"]}) # wholesale replace
|
||
return list(to_adopt)
|
||
|
||
|
||
def _promote_global_auth_to_provider() -> str | None:
|
||
"""Backfill a databricks providers entry from an existing global ``auth:`` block.
|
||
|
||
Older ``omnigent setup`` runs configured Databricks only via the top-level
|
||
``auth: {type: databricks}`` block — which ``configure harnesses`` does not
|
||
read — so the readout showed no Databricks provider (and an ambient CLI
|
||
login as the default) even though routing used Databricks. This promotes
|
||
that block into a first-class ``kind: databricks`` providers entry the next
|
||
time ``configure harnesses`` opens, so existing configs self-heal without
|
||
re-running ``omnigent setup``.
|
||
|
||
Becomes the default only for families with no existing **provider** default —
|
||
mirroring routing precedence (explicit provider default > ``auth:`` block),
|
||
so an explicitly-chosen default is left untouched while a config that only
|
||
ever had the ``auth:`` block gets Databricks as its default (matching what
|
||
routing already does at runtime). Must run BEFORE
|
||
:func:`_adopt_detected_providers` so Databricks claims the default ahead of
|
||
an ambient CLI login (``auth:`` outranks ambient detection in routing too).
|
||
|
||
:returns: ``"databricks"`` if a provider was backfilled, else ``None`` (no
|
||
databricks ``auth:`` block, or a databricks provider already exists).
|
||
"""
|
||
from omnigent.onboarding.configure_models import build_databricks_provider_entry
|
||
from omnigent.onboarding.provider_config import (
|
||
load_providers,
|
||
provider_entry_settings,
|
||
provider_families,
|
||
set_default_provider,
|
||
surface_default_provider,
|
||
)
|
||
|
||
config = _load_global_config()
|
||
auth = config.get("auth")
|
||
if not isinstance(auth, dict) or auth.get("type") != "databricks":
|
||
return None
|
||
profile = auth.get("profile")
|
||
if not isinstance(profile, str) or not profile:
|
||
return None
|
||
name = "databricks"
|
||
if name in load_providers(config):
|
||
return None # already a first-class provider — nothing to backfill
|
||
|
||
entry = build_databricks_provider_entry(profile)
|
||
_save_global_config(
|
||
provider_entry_settings(name, entry, make_default=False),
|
||
deep_merge_keys=("providers",),
|
||
)
|
||
parsed = load_providers({"providers": {name: entry}})[name]
|
||
for fam in sorted(provider_families(parsed)):
|
||
cfg = _load_global_config()
|
||
# Effective check (matters for the pi surface): a default that
|
||
# already drives the surface — explicitly or via pi's fallback —
|
||
# outranks the legacy auth: block, exactly like routing does.
|
||
if surface_default_provider(cfg, fam) is not None:
|
||
continue # respect an existing provider default (it outranks auth:)
|
||
block = cfg.get("providers")
|
||
if isinstance(block, dict):
|
||
_save_global_config({"providers": set_default_provider(block, name, fam)})
|
||
return name
|
||
|
||
|
||
def _compact_credential_label(det: DetectedProvider) -> str:
|
||
"""A short, brand-qualified label for an auto-configured credential.
|
||
|
||
Unlike :func:`omnigent.onboarding.configure_models.credential_label`
|
||
(which renders every CLI login as a bare ``"Subscription"`` because a
|
||
harness only ever has one), this names the *brand* behind a login —
|
||
``"Claude Subscription"`` / ``"ChatGPT Subscription"`` — so a single
|
||
comma-joined callout listing several credentials at once stays unambiguous
|
||
without a per-line source. API keys and local endpoints reuse the shared
|
||
``credential_label`` (``"Anthropic API Key"``, ``"Ollama"``).
|
||
|
||
:param det: A credential found by
|
||
:func:`omnigent.onboarding.ambient.detect_providers`.
|
||
:returns: A short human label, e.g. ``"Anthropic API Key"``,
|
||
``"Claude Subscription"``, or ``"ChatGPT Subscription"``.
|
||
"""
|
||
from omnigent.onboarding.ambient import SUBSCRIPTION_KIND
|
||
from omnigent.onboarding.configure_models import credential_label
|
||
|
||
if det.kind == SUBSCRIPTION_KIND:
|
||
# Fallback to the raw CLI name is unreachable for today's detections
|
||
# (see _CLI_LOGIN_BRAND) but keeps an added CLI readable, not crashing.
|
||
brand = _CLI_LOGIN_BRAND.get(det.name, det.name)
|
||
return f"{brand} Subscription"
|
||
# A cli-config detection carries the provider's own display name
|
||
# ("Databricks AI Gateway"); other kinds ignore the keyword.
|
||
return credential_label(det.kind, det.name, display_name=det.display_name)
|
||
|
||
|
||
def _announce_auto_configured_credentials(adopted: list[str]) -> None:
|
||
"""Print the "found existing credentials → auto-configured" callout.
|
||
|
||
Re-runs ambient detection to recover each adopted credential, then prints a
|
||
single compact, dimmed line naming them inline (e.g. ``Anthropic API Key,
|
||
Claude Subscription, ChatGPT Subscription``) — so a user who never ran an
|
||
explicit setup sees, the first time we auto-configure, exactly which
|
||
credentials omnigent picked up (rather than silently inheriting them).
|
||
Styled ``dim`` rather than the onboarding accent so it reads as a quiet
|
||
notice, not a prominent header.
|
||
|
||
:param adopted: Provider names just persisted by
|
||
:func:`_adopt_detected_providers`, e.g. ``["anthropic", "codex"]``.
|
||
A name with no matching live detection is skipped (defensive — the
|
||
adopt set and the detection list come from the same detection pass, so
|
||
in practice every name resolves).
|
||
:returns: None. Side effect: writes the callout to the shared onboarding
|
||
console (stdout). Prints nothing when no adopted name resolves to a
|
||
live detection.
|
||
"""
|
||
from omnigent.onboarding.ambient import detect_providers
|
||
from omnigent.onboarding.interactive import console
|
||
|
||
detected = {det.name: det for det in detect_providers()}
|
||
labels = [_compact_credential_label(detected[name]) for name in adopted if name in detected]
|
||
if not labels:
|
||
return
|
||
console.print(
|
||
"\n[dim]Found existing credentials on your machine, "
|
||
f"auto-configured for omnigent: {', '.join(labels)}[/dim]"
|
||
)
|
||
|
||
|
||
def _adopt_ambient_credentials(progress: RunnerStartupProgress | None = None) -> list[str]:
|
||
"""Self-heal config, adopt ambient credentials, and announce what was added.
|
||
|
||
The shared front half of both a bare ``omnigent run``'s first-run path
|
||
(:func:`_resolve_first_run_plan`) and the ``configure harnesses`` picker
|
||
(:func:`_run_configure_harnesses_interactive`): it (1) backfills a legacy
|
||
databricks ``auth:`` block into a real provider, (2) adopts any
|
||
ambient-detected credential (env API key, logged-in ``claude`` / ``codex``
|
||
CLI, local Ollama) not already configured as an ordinary provider entry,
|
||
and (3) prints a callout naming exactly the credentials it just
|
||
auto-configured. Idempotent: a second open adopts nothing, so no callout
|
||
prints.
|
||
|
||
The callout is scoped to *machine* credentials — the ambient detections —
|
||
not the databricks ``auth:`` backfill, which promotes an existing config
|
||
block rather than something newly "found on your machine".
|
||
|
||
:param progress: Optional spinner handle (from
|
||
:func:`omnigent._runner_startup.runner_startup_progress`) covering the
|
||
detection step — slow on macOS, where Claude detection now shells out to
|
||
``claude auth status`` to read the Keychain. When supplied, it is
|
||
``finish()``-ed (the spinner cleared) right before the callout prints,
|
||
so the "Found existing credentials…" line is not clobbered by the
|
||
animating spinner. ``None`` (the ``run`` first-run path) means no
|
||
spinner — behavior is unchanged.
|
||
:returns: The provider names adopted this call, e.g. ``["anthropic"]``;
|
||
empty when every detection was already configured.
|
||
"""
|
||
_promote_global_auth_to_provider()
|
||
adopted = _adopt_detected_providers()
|
||
# Clear the search spinner (if any) before printing — the callout writes to
|
||
# stdout while the spinner animates on stderr, and on a shared TTY the two
|
||
# would otherwise overwrite each other.
|
||
if progress is not None:
|
||
progress.finish()
|
||
if adopted:
|
||
_announce_auto_configured_credentials(adopted)
|
||
return adopted
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _HarnessMenuRow:
|
||
"""One selectable row in a harness's provider-management menu (level 2).
|
||
|
||
:param label: Display text, e.g. ``"🔑 anthropic ✓ default"``.
|
||
:param action: The action on Enter — ``"set_default"`` / ``"add"`` /
|
||
``"remove"`` / ``"back"``.
|
||
:param provider: For ``set_default``, the provider name to default;
|
||
``None`` for the other actions.
|
||
"""
|
||
|
||
label: str
|
||
action: str
|
||
provider: str | None = None
|
||
|
||
|
||
_SOFT_INSTALL_ABORT = "\x00soft-install-abort"
|
||
|
||
|
||
def _credential_label(name: str, entry: ProviderEntry) -> str:
|
||
"""A friendly, jargon-free label for a configured credential.
|
||
|
||
A logged-in CLI reads as ``"Subscription"`` (within a harness there is only
|
||
one, so the plan name adds no information); an API-key provider names the
|
||
vendor and the credential type (``"Anthropic API Key"`` / ``"OpenAI API
|
||
Key"``); Databricks as ``"Databricks (<profile>)"``; a gateway / local
|
||
endpoint as its display name — so menus and summaries avoid raw provider
|
||
ids and the word "provider".
|
||
|
||
:param name: The provider id keyed under ``providers:``, e.g. ``"openai"``.
|
||
:param entry: The parsed provider entry.
|
||
:returns: A human label, e.g. ``"Anthropic API Key"`` or ``"Databricks (oss)"``.
|
||
"""
|
||
from omnigent.onboarding.configure_models import credential_label
|
||
|
||
return credential_label(
|
||
entry.kind, name, profile=entry.profile, display_name=entry.display_name
|
||
)
|
||
|
||
|
||
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``.
|
||
|
||
Each credential row drills into level 3 (make default / remove). The
|
||
current default is marked with a green ✓. ``+ Add a credential`` runs the
|
||
add flow; ``← Back`` returns to the harness picker (as do Esc / ``q``).
|
||
|
||
:param config: The parsed config mapping (``providers:`` block).
|
||
:param family: The harness surface being managed.
|
||
:returns: The ordered, all-selectable rows.
|
||
"""
|
||
from omnigent.onboarding.configure_models import kind_glyph
|
||
from omnigent.onboarding.provider_config import (
|
||
load_providers,
|
||
provider_families,
|
||
surface_default_provider,
|
||
)
|
||
|
||
serving = [
|
||
(name, entry)
|
||
for name, entry in load_providers(config).items()
|
||
if family in provider_families(entry)
|
||
]
|
||
# The surface's effective default (for pi: explicit scope, else fallback)
|
||
# so the ✓ always marks the credential the harness would actually use.
|
||
default = surface_default_provider(config, family)
|
||
rows: list[_HarnessMenuRow] = []
|
||
for name, entry in serving:
|
||
glyph = kind_glyph(entry.kind)
|
||
cred = _family_credential_label(config, family, name, entry)
|
||
# The current default renders bold-green with a ✓ so it stands out in
|
||
# the list; the rest are plain. Provider names are markup-safe in
|
||
# practice (same assumption select() already makes for every label).
|
||
if default is not None and name == default.name:
|
||
label = f"[bold green]{glyph} {cred} ✓ default[/]"
|
||
else:
|
||
label = f"{glyph} {cred}"
|
||
rows.append(_HarnessMenuRow(label, action="credential", provider=name))
|
||
rows.append(_HarnessMenuRow("+ Add a credential", action="add"))
|
||
rows.append(_HarnessMenuRow("← Back", action="back"))
|
||
return rows
|
||
|
||
|
||
def _prompt_install_harness(family: str) -> bool:
|
||
"""Offer to install an uninstalled harness CLI; return whether to proceed.
|
||
|
||
Shown when the user drills into a harness whose CLI isn't on PATH. Offers
|
||
three choices: install it now (``npm install -g …``), go back, or print the
|
||
command to run manually.
|
||
|
||
:param family: The harness surface being configured (``"anthropic"`` /
|
||
``"openai"`` / ``"pi"``).
|
||
:returns: ``True`` only when the CLI is installed afterward (user chose
|
||
install and it succeeded), so the caller continues to credential
|
||
configuration; ``False`` when the user declines, asks to run it
|
||
themselves, the install fails, or they Esc — the caller returns to the
|
||
harness picker.
|
||
"""
|
||
from omnigent.onboarding.configure_models import family_label
|
||
from omnigent.onboarding.harness_install import (
|
||
harness_install_command,
|
||
install_harness_cli,
|
||
)
|
||
from omnigent.onboarding.interactive import console, select
|
||
|
||
label = family_label(family)
|
||
cmd = " ".join(harness_install_command(family))
|
||
choice = select(
|
||
f"{label}'s CLI isn't installed. Install it now?",
|
||
[
|
||
f"Yes — install ({cmd})",
|
||
"No — back to harnesses",
|
||
"I'll run it myself (show the command)",
|
||
],
|
||
descriptions=[
|
||
f"Runs `{cmd}` (needs npm), then continues to credential setup.",
|
||
"Return to the harness picker without installing.",
|
||
"Print the command so you can install it yourself, then return.",
|
||
],
|
||
default=0,
|
||
clear_on_exit=True,
|
||
)
|
||
if choice == 0:
|
||
console.print(f" [dim]Installing {label} — running `{cmd}`…[/dim]")
|
||
if install_harness_cli(family):
|
||
console.print(f" [green]✓ {label} installed[/green]")
|
||
return True
|
||
console.print(
|
||
f" [red]Install failed.[/red] Run it manually, then re-open: [bold]{cmd}[/bold]"
|
||
)
|
||
return False
|
||
if choice == 2: # run it yourself
|
||
console.print(f" Install {label} with:\n [bold]{cmd}[/bold]")
|
||
return False
|
||
|
||
|
||
def _manage_harness_providers(family: str) -> None:
|
||
"""Run the level-2 loop for one harness: pick a credential or add one.
|
||
|
||
Selecting a credential opens level 3 (make default / remove); ``+ Add``
|
||
runs the add flow. Esc (TTY) / ``q`` (fallback) returns to the harness
|
||
picker. The menu re-renders (cleared in place) after each action so the
|
||
session stays on one tidy screen.
|
||
|
||
:param family: The harness family being managed.
|
||
:returns: None.
|
||
"""
|
||
from omnigent.onboarding.configure_models import family_label
|
||
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 not harness_cli_installed(family) and not _prompt_install_harness(family):
|
||
return
|
||
|
||
# Carry the prior action's confirmation as a transient status line so the
|
||
# menu shows only the latest result — not an accumulating stack of "✓ …".
|
||
status: str | None = None
|
||
while True:
|
||
rows = _harness_credential_rows(_load_global_config(), family)
|
||
idx = select(
|
||
f"{family_label(family)} — select or add a credential",
|
||
[r.label for r in rows],
|
||
clear_on_exit=True,
|
||
status=status,
|
||
)
|
||
if idx < 0: # Esc / q — back to the harness picker
|
||
return
|
||
row = rows[idx]
|
||
if row.action == "back":
|
||
return
|
||
if row.action == "add":
|
||
status = _configure_harness_add(family=family)
|
||
elif row.action == "credential" and row.provider is not None:
|
||
status = _manage_credential(row.provider, family)
|
||
|
||
|
||
def _prompt_install_cursor() -> str | None:
|
||
"""Offer to install the missing ``cursor`` extra; return a status line.
|
||
|
||
Shown atop the Cursor drill-in when the optional-extra ``cursor-sdk`` is
|
||
absent. Three-choice ``select`` like :func:`_prompt_install_antigravity` /
|
||
:func:`_prompt_install_harness` (install now / set key anyway / show
|
||
command), but does NOT gate key management on the SDK: the ``cursor:`` key
|
||
is stored independently and is useful once the SDK lands, so declining falls
|
||
through to the key menu (whereas ``_prompt_install_harness`` returns to the
|
||
picker, since pi can't configure credentials without its CLI). Install is
|
||
portable and index-free — see
|
||
:func:`omnigent.onboarding.cursor_auth.cursor_install_command`.
|
||
|
||
:returns: Status string for the drill-in's transient status line, or
|
||
``None`` (set-key-anyway / Esc / printed-command, no actionable result).
|
||
"""
|
||
from rich.markup import escape as _rich_escape
|
||
|
||
from omnigent.onboarding.cursor_auth import CURSOR_EXTRA, install_cursor_sdk
|
||
from omnigent.onboarding.extra_install import extra_install_display
|
||
from omnigent.onboarding.interactive import console, select
|
||
|
||
cmd = extra_install_display(CURSOR_EXTRA)
|
||
# ``select`` renders text through Rich markup; escape the literal
|
||
# ``[cursor]`` so it renders verbatim.
|
||
cmd_markup = _rich_escape(cmd)
|
||
choice = select(
|
||
"Cursor's SDK (cursor-sdk) isn't installed. Install it now?",
|
||
[
|
||
f"Install it now ({cmd_markup})",
|
||
"Set the Cursor key anyway",
|
||
"I'll run it myself (show the command)",
|
||
],
|
||
descriptions=[
|
||
f"Runs `{cmd_markup}`, then continues.",
|
||
"Skip the install — store the key now; the SDK can be added later.",
|
||
"Print the command so you can install it yourself, then continue.",
|
||
],
|
||
default=0,
|
||
clear_on_exit=True,
|
||
)
|
||
if choice == 0:
|
||
console.print(f" [dim]Installing the cursor extra — running `{cmd_markup}`…[/dim]")
|
||
if install_cursor_sdk():
|
||
console.print(" [green]✓ cursor-sdk installed[/green]")
|
||
return "✓ cursor-sdk installed"
|
||
console.print(f" [red]Install failed.[/red] Run it manually: [bold]{cmd_markup}[/bold]")
|
||
return "✗ Install failed — set the key anyway, or install by hand"
|
||
if choice < 0:
|
||
return _SOFT_INSTALL_ABORT
|
||
if choice == 2: # run it yourself
|
||
console.print(f" Install the cursor extra with:\n [bold]{cmd_markup}[/bold]")
|
||
return None
|
||
# choice == 1 (set key anyway): fall through to the key menu silently.
|
||
return None
|
||
|
||
|
||
def _manage_cursor_harness() -> None:
|
||
"""Run the level-2 loop for Cursor: manage its ``CURSOR_API_KEY``.
|
||
|
||
Cursor runs via the ``cursor-sdk`` package and authenticates against
|
||
Cursor's own backend with a ``CURSOR_API_KEY`` — the SDK requires one (a
|
||
``cursor-agent login`` does not apply, and cursor has no provider/gateway
|
||
family). So this manages exactly that credential: set / replace / remove an
|
||
API key stored in the omnigent secret store, mirroring how the other
|
||
harnesses persist their api keys (the secret in the store, a
|
||
``keychain:``/``env:`` reference in ``~/.omnigent/config.yaml``).
|
||
|
||
When the optional ``cursor-sdk`` is missing, the drill-in first offers to
|
||
install it (:func:`_prompt_install_cursor`). Unlike the CLI-backed harnesses
|
||
(which gate on the CLI), declining still drops into the key menu — the
|
||
``cursor:`` key is independently storable. Mirrors Antigravity post-#322.
|
||
|
||
:returns: None. Side effects: may install the ``cursor`` extra, and may
|
||
write the ``cursor:`` block of ``~/.omnigent/config.yaml`` and the
|
||
secret store.
|
||
"""
|
||
from omnigent.onboarding import secrets as secret_store
|
||
from omnigent.onboarding.cursor_auth import (
|
||
cursor_api_key_configured,
|
||
cursor_api_key_ref,
|
||
cursor_sdk_installed,
|
||
)
|
||
from omnigent.onboarding.interactive import select
|
||
|
||
# Offer the install once on entry (not per loop iteration) when the SDK is
|
||
# absent; the result seeds the menu's status line. Declining falls through
|
||
# to key management, since the key is SDK-independent.
|
||
status: str | None = None
|
||
if not cursor_sdk_installed():
|
||
status = _prompt_install_cursor()
|
||
if status == _SOFT_INSTALL_ABORT:
|
||
return
|
||
while True:
|
||
config = _load_global_config()
|
||
key_set = cursor_api_key_configured(config)
|
||
|
||
rows: list[_HarnessMenuRow] = [
|
||
_HarnessMenuRow(
|
||
"Replace API key (CURSOR_API_KEY)" if key_set else "Set API key (CURSOR_API_KEY)",
|
||
action="set_key",
|
||
)
|
||
]
|
||
if key_set:
|
||
rows.append(_HarnessMenuRow("Remove API key", action="remove_key"))
|
||
rows.append(_HarnessMenuRow("← Back", action="back"))
|
||
|
||
header = "Cursor — API key configured" if key_set else "Cursor — no API key yet"
|
||
idx = select(header, [r.label for r in rows], clear_on_exit=True, status=status)
|
||
if idx < 0: # Esc / q
|
||
return
|
||
action = rows[idx].action
|
||
if action == "back":
|
||
return
|
||
if action == "set_key":
|
||
status = _set_cursor_api_key()
|
||
elif action == "remove_key":
|
||
ref = cursor_api_key_ref(config)
|
||
# Only a keychain-stored secret is ours to delete; an ``env:`` ref
|
||
# points at the user's own environment, so just drop the config.
|
||
if ref is not None and ref.startswith("keychain:"):
|
||
secret_store.delete_secret(ref[len("keychain:") :])
|
||
_save_global_config({}, unset_keys=("cursor",))
|
||
status = "✓ Removed Cursor API key"
|
||
|
||
|
||
def _set_cursor_api_key() -> str | None:
|
||
"""Prompt for and store a Cursor ``CURSOR_API_KEY``; return a status line.
|
||
|
||
Offers an existing ``CURSOR_API_KEY`` from the environment first (recorded
|
||
as an ``env:`` reference, so the secret never enters the config or the
|
||
secret store), else reads the key with a hidden prompt and stores it in the
|
||
omnigent secret store under ``keychain:cursor``. The ``crsr_`` prefix is
|
||
validated with a soft warning so a wrong paste is caught without
|
||
hard-blocking a future key format. The key value is never echoed.
|
||
|
||
:returns: A confirmation string for the menu's transient status, or
|
||
``None`` when the user aborted (empty input / declined the warning).
|
||
"""
|
||
from omnigent.onboarding import secrets as secret_store
|
||
from omnigent.onboarding.cursor_auth import (
|
||
CURSOR_SECRET_NAME,
|
||
cursor_api_key_settings,
|
||
looks_like_cursor_api_key,
|
||
)
|
||
from omnigent.onboarding.interactive import prompt_text
|
||
|
||
# Strip surrounding whitespace before validating/forwarding so a key
|
||
# exported with a trailing newline (a common ``export $(…)`` mishap)
|
||
# validates and resolves cleanly — matching the pasted-key branch's
|
||
# ``.strip()`` below and the strip in ``resolve_secret``'s ``env:`` branch.
|
||
raw_detected = os.environ.get("CURSOR_API_KEY")
|
||
detected = raw_detected.strip() if raw_detected else None
|
||
if detected and click.confirm(
|
||
"Detected CURSOR_API_KEY in the environment — use it?", default=True
|
||
):
|
||
if not looks_like_cursor_api_key(detected) and not click.confirm(
|
||
"$CURSOR_API_KEY doesn't start with 'crsr_'. Use it anyway?", default=False
|
||
):
|
||
return None
|
||
_save_global_config(cursor_api_key_settings("env:CURSOR_API_KEY"))
|
||
return "✓ Cursor API key set (from $CURSOR_API_KEY)"
|
||
|
||
pasted = prompt_text("Cursor API key (CURSOR_API_KEY)", hide_input=True).strip()
|
||
if not pasted:
|
||
return None
|
||
if not looks_like_cursor_api_key(pasted) and not click.confirm(
|
||
"That doesn't start with 'crsr_'. Store it anyway?", default=False
|
||
):
|
||
return None
|
||
secret_store.store_secret(CURSOR_SECRET_NAME, pasted)
|
||
_save_global_config(cursor_api_key_settings(f"keychain:{CURSOR_SECRET_NAME}"))
|
||
return "✓ Cursor API key stored"
|
||
|
||
|
||
def _prompt_install_antigravity() -> str | None:
|
||
"""Offer to install the missing ``antigravity`` extra; return a status line.
|
||
|
||
Shown atop the Antigravity drill-in when the ``google-antigravity`` SDK is absent.
|
||
Mirrors :func:`_prompt_install_harness` — a three-choice ``select`` (install now /
|
||
set key anyway / print command) — but does NOT gate key management on the SDK:
|
||
unlike pi (which can't be configured without its CLI), the ``antigravity:`` key is
|
||
storable independently, so declining just falls through to the key menu. The
|
||
install carries no index URL (see :func:`antigravity_install_command`); on failure
|
||
it prints the command to run by hand.
|
||
|
||
:returns: A status string for the drill-in's transient status (install result or
|
||
printed-command note), or ``None`` on set-key-anyway / Esc.
|
||
"""
|
||
from rich.markup import escape as _rich_escape
|
||
|
||
from omnigent.onboarding.antigravity_auth import ANTIGRAVITY_EXTRA, install_antigravity_sdk
|
||
from omnigent.onboarding.extra_install import extra_install_display
|
||
from omnigent.onboarding.interactive import console, select
|
||
|
||
cmd = extra_install_display(ANTIGRAVITY_EXTRA)
|
||
# ``select`` renders through Rich markup, so escape the literal ``[antigravity]``.
|
||
cmd_markup = _rich_escape(cmd)
|
||
choice = select(
|
||
"Antigravity's SDK (google-antigravity) isn't installed. Install it now?",
|
||
[
|
||
f"Install it now ({cmd_markup})",
|
||
"Set the Gemini key anyway",
|
||
"I'll run it myself (show the command)",
|
||
],
|
||
descriptions=[
|
||
f"Runs `{cmd_markup}`, then continues.",
|
||
"Skip the install — store the key now; the SDK can be added later.",
|
||
"Print the command so you can install it yourself, then continue.",
|
||
],
|
||
default=0,
|
||
clear_on_exit=True,
|
||
)
|
||
if choice == 0:
|
||
console.print(f" [dim]Installing the antigravity extra — running `{cmd_markup}`…[/dim]")
|
||
if install_antigravity_sdk():
|
||
console.print(" [green]✓ google-antigravity installed[/green]")
|
||
return "✓ google-antigravity installed"
|
||
console.print(f" [red]Install failed.[/red] Run it manually: [bold]{cmd_markup}[/bold]")
|
||
return "✗ Install failed — set the key anyway, or install by hand"
|
||
if choice < 0:
|
||
return _SOFT_INSTALL_ABORT
|
||
if choice == 2:
|
||
console.print(f" Install the antigravity extra with:\n [bold]{cmd_markup}[/bold]")
|
||
return None
|
||
# choice == 1 (set key anyway): fall through to the key menu silently.
|
||
return None
|
||
|
||
|
||
def _manage_antigravity_harness() -> None:
|
||
"""Run the level-2 loop for Antigravity: set / replace / remove its Gemini key.
|
||
|
||
Antigravity is Gemini-native (no provider family), so this manages just its
|
||
API key — stored in the secret store, referenced from the ``antigravity:``
|
||
config block — mirroring how the other harnesses persist api keys.
|
||
|
||
When the optional ``google-antigravity`` SDK is missing, the drill-in first offers
|
||
to install it (:func:`_prompt_install_antigravity`). Unlike the CLI-backed harnesses
|
||
(whose drill-in *gates* on the CLI), declining here still drops into the key menu,
|
||
since the ``antigravity:`` key is independently storable.
|
||
|
||
:returns: None. Side effects: may install the ``antigravity`` extra, and may write
|
||
the ``antigravity:`` config block and the secret store.
|
||
"""
|
||
from omnigent.onboarding import secrets as secret_store
|
||
from omnigent.onboarding.antigravity_auth import (
|
||
ANTIGRAVITY_CONFIG_KEY,
|
||
ANTIGRAVITY_SECRET_NAME,
|
||
antigravity_api_key_configured,
|
||
antigravity_api_key_ref,
|
||
antigravity_sdk_installed,
|
||
)
|
||
from omnigent.onboarding.interactive import select
|
||
|
||
# Offer the install once on entry (not per loop iteration); the returned status
|
||
# seeds the menu's transient status line.
|
||
status: str | None = None
|
||
if not antigravity_sdk_installed():
|
||
status = _prompt_install_antigravity()
|
||
if status == _SOFT_INSTALL_ABORT:
|
||
return
|
||
while True:
|
||
config = _load_global_config()
|
||
key_set = antigravity_api_key_configured(config)
|
||
|
||
rows: list[_HarnessMenuRow] = [
|
||
_HarnessMenuRow(
|
||
"Replace Gemini API key" if key_set else "Set Gemini API key",
|
||
action="set_key",
|
||
)
|
||
]
|
||
if key_set:
|
||
rows.append(_HarnessMenuRow("Remove API key", action="remove_key"))
|
||
rows.append(_HarnessMenuRow("← Back", action="back"))
|
||
|
||
header = (
|
||
"Antigravity — Gemini API key configured"
|
||
if key_set
|
||
else "Antigravity — no Gemini API key yet"
|
||
)
|
||
idx = select(header, [r.label for r in rows], clear_on_exit=True, status=status)
|
||
if idx < 0: # Esc / q
|
||
return
|
||
action = rows[idx].action
|
||
if action == "back":
|
||
return
|
||
if action == "set_key":
|
||
status = _set_antigravity_api_key()
|
||
elif action == "remove_key":
|
||
ref = antigravity_api_key_ref(config)
|
||
# Only the secret we own (``keychain:antigravity``) is ours to
|
||
# delete: a hand-edited block may point at a shared ``keychain:<other>``
|
||
# secret, and an ``env:`` ref names the user's own environment. In
|
||
# both of those cases just drop the config block and leave the secret.
|
||
if ref == f"keychain:{ANTIGRAVITY_SECRET_NAME}":
|
||
secret_store.delete_secret(ANTIGRAVITY_SECRET_NAME)
|
||
_save_global_config({}, unset_keys=(ANTIGRAVITY_CONFIG_KEY,))
|
||
status = "✓ Removed Gemini API key"
|
||
|
||
|
||
def _set_antigravity_api_key() -> str | None:
|
||
"""Prompt for and store a Gemini API key; return a status line.
|
||
|
||
Offers an existing ``GEMINI_API_KEY`` / ``ANTIGRAVITY_API_KEY`` first
|
||
(recorded as an ``env:`` ref, so the secret stays in the environment), else
|
||
reads it with a hidden prompt and stores it under ``keychain:antigravity``.
|
||
The key prefix (``AIza`` or ``AQ``) is checked softly (a wrong paste is
|
||
caught but can be forced). The key is never echoed.
|
||
|
||
:returns: A status string for the menu, or ``None`` if the user aborted.
|
||
"""
|
||
from omnigent.onboarding import secrets as secret_store
|
||
from omnigent.onboarding.antigravity_auth import (
|
||
ANTIGRAVITY_API_KEY_PREFIX_HINT,
|
||
ANTIGRAVITY_ENV_VARS,
|
||
ANTIGRAVITY_SECRET_NAME,
|
||
antigravity_api_key_settings,
|
||
looks_like_gemini_api_key,
|
||
)
|
||
from omnigent.onboarding.interactive import prompt_text
|
||
|
||
detected_var = next((v for v in ANTIGRAVITY_ENV_VARS if os.environ.get(v)), None)
|
||
if detected_var is not None and click.confirm(
|
||
f"Detected {detected_var} in the environment — use it?", default=True
|
||
):
|
||
detected = os.environ[detected_var]
|
||
if not looks_like_gemini_api_key(detected) and not click.confirm(
|
||
f"${detected_var} doesn't start with {ANTIGRAVITY_API_KEY_PREFIX_HINT}. "
|
||
"Use it anyway?",
|
||
default=False,
|
||
):
|
||
return None
|
||
_save_global_config(antigravity_api_key_settings(f"env:{detected_var}"))
|
||
return f"✓ Gemini API key set (from ${detected_var})"
|
||
|
||
pasted = prompt_text("Gemini API key (GEMINI_API_KEY)", hide_input=True).strip()
|
||
if not pasted:
|
||
return None
|
||
if not looks_like_gemini_api_key(pasted) and not click.confirm(
|
||
f"That doesn't start with {ANTIGRAVITY_API_KEY_PREFIX_HINT}. Store it anyway?",
|
||
default=False,
|
||
):
|
||
return None
|
||
secret_store.store_secret(ANTIGRAVITY_SECRET_NAME, pasted)
|
||
_save_global_config(antigravity_api_key_settings(f"keychain:{ANTIGRAVITY_SECRET_NAME}"))
|
||
return "✓ Gemini API key stored"
|
||
|
||
|
||
def _qwen_auth_configured() -> bool:
|
||
"""Best-effort check whether Qwen Code can authenticate non-interactively.
|
||
|
||
Qwen has **no CLI login** — its ``auth`` subcommand was removed. For our
|
||
``qwen --acp`` executor, auth must come from one of:
|
||
|
||
- API-key / provider env vars (the headless path): ``OPENAI_API_KEY``,
|
||
``BAILIAN_CODING_PLAN_API_KEY``, or ``OPENROUTER_API_KEY``; or
|
||
- an auth type selected via the interactive ``/auth`` flow (API key or the
|
||
Alibaba Cloud Coding Plan), persisted to ``~/.qwen/settings.json``.
|
||
|
||
(Qwen OAuth was discontinued on 2026-04-15, so it is not an auth path here.)
|
||
|
||
Best-effort: the env-var check is reliable; the on-disk check keys off
|
||
``settings.json`` fields whose schema is not contract-stable (see
|
||
docs/QWEN_FOLLOWUPS.md). Returns ``False`` for a fresh install with no auth —
|
||
the case that must NOT render as "signed in".
|
||
|
||
:returns: ``True`` when auth is detectable, else ``False``.
|
||
"""
|
||
from pathlib import Path
|
||
|
||
if any(
|
||
os.environ.get(v)
|
||
for v in ("OPENAI_API_KEY", "BAILIAN_CODING_PLAN_API_KEY", "OPENROUTER_API_KEY")
|
||
):
|
||
return True
|
||
settings = Path.home() / ".qwen" / "settings.json"
|
||
if settings.is_file():
|
||
try:
|
||
data = json.loads(settings.read_text())
|
||
except (OSError, ValueError):
|
||
return False
|
||
if isinstance(data, dict):
|
||
if data.get("selectedAuthType"):
|
||
return True
|
||
security = data.get("security")
|
||
auth = security.get("auth") if isinstance(security, dict) else None
|
||
if isinstance(auth, dict) and (
|
||
auth.get("selectedType") or auth.get("selectedAuthType")
|
||
):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _print_qwen_auth_help() -> None:
|
||
"""Print Qwen's authentication options (it has no ``qwen login``)."""
|
||
from omnigent.onboarding.interactive import console
|
||
|
||
console.print(
|
||
"\n [bold]Authenticate Qwen Code[/bold]:\n"
|
||
" • Interactive: run [bold]qwen[/bold] and use [bold]/auth[/bold] "
|
||
"(API key or Alibaba Cloud Coding Plan)\n"
|
||
" • Headless / ACP: set [bold]OPENAI_API_KEY[/bold] + "
|
||
"[bold]OPENAI_BASE_URL[/bold] + [bold]OPENAI_MODEL[/bold]\n"
|
||
" • Coding Plan: [bold]BAILIAN_CODING_PLAN_API_KEY[/bold] + the "
|
||
"Coding Plan base URL\n"
|
||
" • OpenRouter: [bold]OPENROUTER_API_KEY[/bold] + "
|
||
"OPENAI_BASE_URL=https://openrouter.ai/api/v1\n"
|
||
)
|
||
|
||
|
||
def _launch_qwen_auth() -> str | None:
|
||
"""Launch the interactive ``qwen`` TUI so the user can run ``/auth``.
|
||
|
||
The ``/auth`` flow (API key or Alibaba Cloud Coding Plan) is interactive, so
|
||
this hands the terminal to ``qwen``; when the user exits, re-check auth.
|
||
|
||
:returns: A status line for the menu reflecting the post-launch auth state.
|
||
"""
|
||
from omnigent.onboarding.harness_install import (
|
||
QWEN_KEY,
|
||
harness_cli_installed,
|
||
harness_install_spec,
|
||
)
|
||
from omnigent.onboarding.interactive import console
|
||
|
||
if not harness_cli_installed(QWEN_KEY):
|
||
return "✗ qwen CLI not found"
|
||
spec = harness_install_spec(QWEN_KEY)
|
||
assert spec is not None
|
||
console.print(
|
||
" [dim]Launching Qwen — type [bold]/auth[/bold] to configure authentication, "
|
||
"then exit (/quit) to return.[/dim]"
|
||
)
|
||
with contextlib.suppress(OSError, KeyboardInterrupt):
|
||
subprocess.run([spec.binary], check=False)
|
||
return "✓ authentication detected" if _qwen_auth_configured() else "Auth not detected yet"
|
||
|
||
|
||
def _manage_qwen_harness() -> None:
|
||
"""Run the level-2 loop for Qwen Code: install the CLI and guide auth setup.
|
||
|
||
Qwen has **no CLI subscription login** — its ``auth`` subcommand was removed.
|
||
Authentication is either OpenAI-compatible env vars (for the headless
|
||
``qwen --acp`` path) or the interactive ``/auth`` command (API key or
|
||
Alibaba Cloud Coding Plan). So this drill-in installs the CLI when missing,
|
||
reports best-effort auth status (:func:`_qwen_auth_configured`), and offers
|
||
to launch ``qwen`` for ``/auth`` — it does **not** pretend to run a ``qwen
|
||
login``
|
||
(there isn't one). Storing/injecting an OpenAI-compatible key *through
|
||
Omnigent* is deferred (see docs/QWEN_FOLLOWUPS.md, Provider Injection).
|
||
|
||
Like the CLI-backed harnesses, a missing CLI gates the drill-in — there's
|
||
nothing to configure for a harness you can't run.
|
||
|
||
:returns: None. Side effects: may ``npm install`` the qwen CLI and launch the
|
||
interactive ``qwen`` TUI for ``/auth``.
|
||
"""
|
||
from omnigent.onboarding.harness_install import (
|
||
QWEN_KEY,
|
||
harness_cli_installed,
|
||
harness_install_command,
|
||
install_harness_cli,
|
||
)
|
||
from omnigent.onboarding.interactive import console, select
|
||
|
||
# Gate on the CLI. Offer to install it; declining (or copy-the-command)
|
||
# returns to the harness picker.
|
||
if not harness_cli_installed(QWEN_KEY):
|
||
cmd = " ".join(harness_install_command(QWEN_KEY))
|
||
choice = select(
|
||
"Qwen Code's CLI isn't installed. Install it now?",
|
||
[
|
||
f"Yes — install ({cmd})",
|
||
"No — back to harnesses",
|
||
"I'll run it myself (show the command)",
|
||
],
|
||
descriptions=[
|
||
f"Runs `{cmd}` (needs npm), then continues to auth setup.",
|
||
"Return to the harness picker without installing.",
|
||
"Print the command so you can install it yourself, then return.",
|
||
],
|
||
default=0,
|
||
clear_on_exit=True,
|
||
)
|
||
if choice == 0:
|
||
console.print(f" [dim]Installing Qwen Code — running `{cmd}`…[/dim]")
|
||
if install_harness_cli(QWEN_KEY):
|
||
console.print(" [green]✓ Qwen Code installed[/green]")
|
||
else:
|
||
console.print(
|
||
f" [red]Install failed.[/red] Run it manually, then re-open: "
|
||
f"[bold]{cmd}[/bold]"
|
||
)
|
||
return
|
||
else:
|
||
if choice == 2: # run it yourself
|
||
console.print(f" Install Qwen Code with:\n [bold]{cmd}[/bold]")
|
||
return
|
||
|
||
# Carry the prior action's confirmation as a transient status line.
|
||
status: str | None = None
|
||
while True:
|
||
configured = _qwen_auth_configured()
|
||
header = (
|
||
"Qwen Code — authentication detected"
|
||
if configured
|
||
else "Qwen Code — not authenticated yet"
|
||
)
|
||
rows: list[_HarnessMenuRow] = [
|
||
_HarnessMenuRow("Open Qwen to run /auth", action="auth"),
|
||
_HarnessMenuRow("Show auth options", action="help"),
|
||
_HarnessMenuRow("← Back", action="back"),
|
||
]
|
||
idx = select(header, [r.label for r in rows], clear_on_exit=True, status=status)
|
||
if idx < 0: # Esc / q
|
||
return
|
||
action = rows[idx].action
|
||
if action == "back":
|
||
return
|
||
if action == "auth":
|
||
status = _launch_qwen_auth()
|
||
elif action == "help":
|
||
_print_qwen_auth_help()
|
||
status = None
|
||
|
||
|
||
def _print_goose_auth_help() -> None:
|
||
"""Print Goose's configuration options (Omnigent manages no Goose credential)."""
|
||
from omnigent.onboarding.interactive import console
|
||
|
||
console.print(
|
||
"\n [bold]Configure Goose[/bold] (Omnigent stores no Goose credential):\n"
|
||
" • Interactive: run [bold]goose configure[/bold] to pick a provider "
|
||
"and store its key (keyring or ~/.config/goose/config.yaml)\n"
|
||
" • Env override: set [bold]GOOSE_PROVIDER[/bold] + [bold]GOOSE_MODEL[/bold] "
|
||
"(plus the provider's key, e.g. ANTHROPIC_API_KEY / OPENAI_API_KEY)\n"
|
||
)
|
||
|
||
|
||
def _launch_goose_configure() -> str | None:
|
||
"""Launch the interactive ``goose configure`` flow; return a status line.
|
||
|
||
``goose configure`` is interactive (pick a provider, enter its key), so this
|
||
hands the terminal to ``goose``; when the user exits, re-read the configured
|
||
provider. Mirrors :func:`_launch_qwen_auth`.
|
||
|
||
:returns: A status line reflecting the post-configure provider state.
|
||
"""
|
||
from omnigent.onboarding.goose_auth import goose_config_summary
|
||
from omnigent.onboarding.harness_install import (
|
||
GOOSE_KEY,
|
||
harness_cli_installed,
|
||
harness_install_spec,
|
||
)
|
||
from omnigent.onboarding.interactive import console
|
||
|
||
if not harness_cli_installed(GOOSE_KEY):
|
||
return "✗ goose CLI not found"
|
||
spec = harness_install_spec(GOOSE_KEY)
|
||
assert spec is not None
|
||
console.print(
|
||
" [dim]Launching [bold]goose configure[/bold] — pick a provider and "
|
||
"enter its key, then return.[/dim]"
|
||
)
|
||
with contextlib.suppress(OSError, KeyboardInterrupt):
|
||
subprocess.run([spec.binary, "configure"], check=False)
|
||
summary = goose_config_summary()
|
||
if summary.provider:
|
||
model = f" ({summary.model})" if summary.model else ""
|
||
return f"✓ provider configured: {summary.provider}{model}"
|
||
return "Provider not detected yet"
|
||
|
||
|
||
def _manage_goose_harness() -> None:
|
||
"""Run the level-2 loop for Goose: ensure the CLI, then guide ``goose configure``.
|
||
|
||
Goose owns its own auth (keyring / ``~/.config/goose/config.yaml``) — Omnigent
|
||
stores no Goose credential — so, like the Qwen drill-in, this reports
|
||
best-effort configuration status and offers to launch ``goose configure``; it
|
||
does not store a key through Omnigent. A missing CLI gates the drill-in
|
||
(nothing to configure for a harness you can't run); Goose ships out-of-band
|
||
(brew / curl, no npm package), so we show its install hint rather than
|
||
auto-installing. Serves both ``goose-native`` (TUI) and the headless
|
||
``goose`` (ACP) harness — both launch the same ``goose`` binary and read the
|
||
same config.
|
||
|
||
:returns: None. Side effects: may launch the interactive ``goose configure``.
|
||
"""
|
||
from omnigent.onboarding.goose_auth import goose_config_summary
|
||
from omnigent.onboarding.harness_install import (
|
||
GOOSE_KEY,
|
||
harness_cli_installed,
|
||
harness_install_spec,
|
||
)
|
||
from omnigent.onboarding.interactive import console, select
|
||
|
||
# Gate on the CLI. Goose installs out-of-band (no npm package), so we can't
|
||
# auto-install — show the hint and return.
|
||
if not harness_cli_installed(GOOSE_KEY):
|
||
spec = harness_install_spec(GOOSE_KEY)
|
||
hint = spec.install_hint if spec and spec.install_hint else "brew install block-goose-cli"
|
||
console.print(
|
||
f" Goose's CLI isn't installed. Install it with:\n [bold]{hint}[/bold]\n"
|
||
" then re-open this menu."
|
||
)
|
||
return
|
||
|
||
status: str | None = None
|
||
while True:
|
||
summary = goose_config_summary()
|
||
if summary.provider:
|
||
model = f" · {summary.model}" if summary.model else ""
|
||
header = f"Goose — provider configured: {summary.provider}{model}"
|
||
else:
|
||
header = "Goose — no provider configured yet"
|
||
rows: list[_HarnessMenuRow] = [
|
||
_HarnessMenuRow("Run goose configure", action="configure"),
|
||
_HarnessMenuRow("Show configuration options", action="help"),
|
||
_HarnessMenuRow("← Back", action="back"),
|
||
]
|
||
idx = select(header, [r.label for r in rows], clear_on_exit=True, status=status)
|
||
if idx < 0: # Esc / q
|
||
return
|
||
action = rows[idx].action
|
||
if action == "back":
|
||
return
|
||
if action == "configure":
|
||
status = _launch_goose_configure()
|
||
elif action == "help":
|
||
_print_goose_auth_help()
|
||
status = None
|
||
|
||
|
||
def _print_acp_examples() -> None:
|
||
"""Print example ACP-agent commands (Omnigent stores no credential)."""
|
||
from omnigent.onboarding.interactive import console
|
||
|
||
console.print(
|
||
"\n [bold]Custom ACP agents[/bold] — connect any agent that speaks the "
|
||
"Agent Client Protocol ([underline]agentclientprotocol.com[/underline]).\n"
|
||
" Omnigent stores no credential — log into each agent via its own CLI first.\n\n"
|
||
" Example commands to paste:\n"
|
||
" • Gemini CLI [bold]gemini --experimental-acp[/bold]\n"
|
||
" • Qwen Code [bold]qwen --acp[/bold]\n"
|
||
" • Goose [bold]goose acp[/bold]\n"
|
||
" • Claude Code [bold]npx -y @zed-industries/claude-code-acp[/bold]\n"
|
||
)
|
||
|
||
|
||
def _add_acp_agent() -> None:
|
||
"""Prompt for a new ACP agent and append it to the ``acp:`` config block.
|
||
|
||
Reached straight from the "Add custom ACP agent" overview row (no
|
||
intermediate menu). Prints the paste-ready examples first, then prompts for
|
||
name / command / optional model.
|
||
"""
|
||
from omnigent.onboarding.acp_auth import (
|
||
AcpAgentEntry,
|
||
acp_agents,
|
||
acp_agents_settings,
|
||
slugify,
|
||
)
|
||
from omnigent.onboarding.interactive import console, prompt_text
|
||
|
||
_print_acp_examples()
|
||
name = prompt_text("Agent name (e.g. Gemini CLI)").strip()
|
||
if not name:
|
||
console.print(" [yellow]No name entered — nothing added.[/yellow]")
|
||
return
|
||
command = prompt_text("Command to launch (e.g. gemini --experimental-acp)").strip()
|
||
if not command:
|
||
console.print(" [yellow]No command entered — nothing added.[/yellow]")
|
||
return
|
||
model = (prompt_text("Model (optional — Enter to skip)", default="") or "").strip() or None
|
||
|
||
entries = list(acp_agents())
|
||
entries.append(AcpAgentEntry(slug=slugify(name), name=name, command=command, model=model))
|
||
_save_global_config(acp_agents_settings(entries))
|
||
console.print(f" ✓ Added {name}")
|
||
|
||
|
||
def _manage_acp_agent(slug: str) -> None:
|
||
"""Per-agent drill-in for one configured ACP agent: remove it.
|
||
|
||
Reached by selecting the agent's own row in the configure-harnesses overview.
|
||
A single-shot menu (Remove / Back) — Omnigent stores no credential, so there
|
||
is nothing else to manage per agent yet.
|
||
|
||
:param slug: The agent's slug (see :func:`omnigent.onboarding.acp_auth.slugify`).
|
||
"""
|
||
from omnigent.onboarding.acp_auth import acp_agents, acp_agents_settings
|
||
from omnigent.onboarding.interactive import console, select
|
||
|
||
agents = list(acp_agents())
|
||
agent = next((a for a in agents if a.slug == slug), None)
|
||
if agent is None:
|
||
return
|
||
suffix = f" · {agent.model}" if agent.model else ""
|
||
header = f"{agent.name} — {agent.command}{suffix}"
|
||
rows: list[_HarnessMenuRow] = [
|
||
_HarnessMenuRow("Remove this agent", action="remove"),
|
||
_HarnessMenuRow("← Back", action="back"),
|
||
]
|
||
idx = select(header, [r.label for r in rows], clear_on_exit=True)
|
||
if idx < 0 or rows[idx].action == "back":
|
||
return
|
||
_save_global_config(acp_agents_settings([a for a in agents if a.slug != slug]))
|
||
console.print(f" ✓ Removed {agent.name}")
|
||
|
||
|
||
def _manage_hermes_harness() -> None:
|
||
"""Run the level-2 loop for Hermes: ensure the CLI is installed.
|
||
|
||
Hermes owns its own auth via ``hermes model`` (interactive provider/model
|
||
picker) and is installed via a curl script from Nous Research — Omnigent
|
||
stores no Hermes credential. A missing CLI gates the drill-in; when
|
||
installed, the drill-in offers to launch ``hermes model`` for provider
|
||
configuration.
|
||
|
||
:returns: None. Side effects: may launch ``hermes model``.
|
||
"""
|
||
from omnigent.onboarding.harness_install import (
|
||
HERMES_KEY,
|
||
harness_cli_installed,
|
||
harness_install_spec,
|
||
)
|
||
from omnigent.onboarding.interactive import console, select
|
||
|
||
if not harness_cli_installed(HERMES_KEY):
|
||
spec = harness_install_spec(HERMES_KEY)
|
||
hint = (
|
||
spec.install_hint
|
||
if spec and spec.install_hint
|
||
else "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash"
|
||
)
|
||
console.print(
|
||
f" Hermes isn't installed. Install it with:\n [bold]{hint}[/bold]\n"
|
||
" then re-open this menu."
|
||
)
|
||
return
|
||
|
||
status: str | None = None
|
||
while True:
|
||
rows: list[_HarnessMenuRow] = [
|
||
_HarnessMenuRow("Run hermes model (configure provider)", action="model"),
|
||
_HarnessMenuRow("← Back", action="back"),
|
||
]
|
||
idx = select(
|
||
"Hermes Agent",
|
||
[r.label for r in rows],
|
||
clear_on_exit=True,
|
||
status=status,
|
||
)
|
||
if idx < 0:
|
||
return
|
||
action = rows[idx].action
|
||
if action == "back":
|
||
return
|
||
if action == "model":
|
||
import subprocess
|
||
|
||
try:
|
||
subprocess.run(["hermes", "model"], check=False)
|
||
status = "✓ hermes model completed"
|
||
except FileNotFoundError:
|
||
status = "✗ hermes binary not found"
|
||
|
||
|
||
def _manage_kiro_harness() -> None:
|
||
"""Run the level-2 loop for Kiro: ensure the CLI is installed and signed in.
|
||
|
||
Kiro owns its own auth via ``kiro-cli login`` (Builder ID / social login /
|
||
Identity Center) and is installed via Kiro's curl installer — Omnigent stores
|
||
no Kiro credential. A missing CLI gates the drill-in; when installed, the
|
||
drill-in offers to launch ``kiro-cli login`` to sign in. Mirrors
|
||
:func:`_manage_hermes_harness`.
|
||
|
||
:returns: None. Side effects: may launch ``kiro-cli login``.
|
||
"""
|
||
from omnigent.onboarding.harness_install import (
|
||
KIRO_KEY,
|
||
harness_cli_installed,
|
||
harness_install_spec,
|
||
)
|
||
from omnigent.onboarding.interactive import console, select
|
||
|
||
if not harness_cli_installed(KIRO_KEY):
|
||
spec = harness_install_spec(KIRO_KEY)
|
||
hint = (
|
||
spec.install_hint
|
||
if spec and spec.install_hint
|
||
else "curl -fsSL https://cli.kiro.dev/install | bash"
|
||
)
|
||
console.print(
|
||
f" Kiro isn't installed. Install it with:\n [bold]{hint}[/bold]\n"
|
||
" then re-open this menu."
|
||
)
|
||
return
|
||
|
||
status: str | None = None
|
||
while True:
|
||
rows: list[_HarnessMenuRow] = [
|
||
_HarnessMenuRow("Run kiro-cli login (sign in)", action="login"),
|
||
_HarnessMenuRow("← Back", action="back"),
|
||
]
|
||
idx = select(
|
||
"Kiro",
|
||
[r.label for r in rows],
|
||
clear_on_exit=True,
|
||
status=status,
|
||
)
|
||
if idx < 0:
|
||
return
|
||
action = rows[idx].action
|
||
if action == "back":
|
||
return
|
||
if action == "login":
|
||
import subprocess
|
||
|
||
try:
|
||
subprocess.run(["kiro-cli", "login"], check=False)
|
||
status = "✓ kiro-cli login completed"
|
||
except FileNotFoundError:
|
||
status = "✗ kiro-cli binary not found"
|
||
|
||
|
||
def _print_kimi_auth_help() -> None:
|
||
"""Print Kimi Code's authentication options.
|
||
|
||
Kimi authenticates against Moonshot AI's backend rather than an Omnigent
|
||
credential: ``kimi login`` (OAuth or a Moonshot API key) for the default
|
||
provider, and ``kimi provider add`` to register any other provider (an
|
||
OpenAI-compatible endpoint, a Databricks gateway, …) in
|
||
``~/.kimi/config.toml``. Omnigent has no per-spawn provider override for
|
||
upstream kimi, so all of this lives in the kimi CLI's own config —
|
||
Omnigent-side injection remains a deferred follow-up.
|
||
"""
|
||
from omnigent.onboarding.interactive import console
|
||
|
||
console.print(
|
||
"\n [bold]Authenticate Kimi Code[/bold] (kimi manages its own config in "
|
||
"~/.kimi/config.toml):\n"
|
||
" • Default provider: run [bold]kimi login[/bold] "
|
||
"(Moonshot OAuth, or paste a Moonshot API key)\n"
|
||
" • Other providers: run [bold]kimi provider add[/bold] "
|
||
"(OpenAI-compatible endpoint, gateway, …), then pin that model id in "
|
||
"the agent spec\n"
|
||
" • Omnigent stores no kimi credential and cannot thread one per "
|
||
"spawn — configure it once in the kimi CLI\n"
|
||
)
|
||
|
||
|
||
def _manage_kimi_harness() -> None:
|
||
"""Run the level-2 loop for Kimi Code: install the CLI and drive ``kimi login``.
|
||
|
||
Unlike Qwen (which has no ``login`` subcommand), Kimi ships a real
|
||
``kimi login`` (Moonshot OAuth or API key) and ``kimi logout``, so this
|
||
drill-in offers sign-in / sign-out directly. Kimi has no first-class
|
||
"am I logged in?" probe (its install spec sets ``status_args=None``), so
|
||
:func:`~omnigent.onboarding.harness_install.harness_cli_logged_in` always
|
||
reports ``False`` for it — meaning ``harness_login`` runs ``kimi login``
|
||
every time it is asked (the interactive flow lets the user cancel if
|
||
already authenticated) and its boolean return is not a reliable success
|
||
signal. We therefore treat login / logout as best-effort side effects and
|
||
report that the flow finished rather than asserting an auth state.
|
||
|
||
Like the other CLI-backed harnesses, a missing CLI gates the drill-in —
|
||
there is nothing to configure for a harness you can't run.
|
||
|
||
:returns: None. Side effects: may install the kimi CLI and run
|
||
``kimi login`` / ``kimi logout`` in the foreground.
|
||
"""
|
||
from omnigent.onboarding.harness_install import (
|
||
KIMI_KEY,
|
||
harness_cli_installed,
|
||
harness_install_spec,
|
||
harness_login,
|
||
harness_logout,
|
||
)
|
||
from omnigent.onboarding.interactive import console, select
|
||
|
||
# Gate on the CLI. Kimi ships a single binary via a curl installer (not
|
||
# npm), so there's no in-process auto-install — name the command and let
|
||
# the user run it, then re-open. Mirrors how ``harness_setup_hint`` treats
|
||
# the other curl-installed CLI (cursor-agent).
|
||
if not harness_cli_installed(KIMI_KEY):
|
||
spec = harness_install_spec(KIMI_KEY)
|
||
hint = (spec.install_hint if spec else None) or "see Kimi Code docs"
|
||
console.print(
|
||
" Kimi Code's CLI isn't installed. Install it with:\n"
|
||
f" [bold]{hint}[/bold]\n"
|
||
" then re-open this menu to sign in."
|
||
)
|
||
return
|
||
|
||
# Carry the prior action's confirmation as a transient status line.
|
||
status: str | None = None
|
||
while True:
|
||
rows: list[_HarnessMenuRow] = [
|
||
_HarnessMenuRow("Sign in (kimi login)", action="login"),
|
||
_HarnessMenuRow("Sign out (kimi logout)", action="logout"),
|
||
_HarnessMenuRow("Show auth options", action="help"),
|
||
_HarnessMenuRow("← Back", action="back"),
|
||
]
|
||
idx = select(
|
||
"Kimi Code — authentication is managed by the kimi CLI",
|
||
[r.label for r in rows],
|
||
clear_on_exit=True,
|
||
status=status,
|
||
)
|
||
if idx < 0: # Esc / q
|
||
return
|
||
action = rows[idx].action
|
||
if action == "back":
|
||
return
|
||
if action == "login":
|
||
# ``kimi login`` runs in the foreground (OAuth / API-key prompt);
|
||
# its boolean return is unreliable for kimi (no status probe), so
|
||
# don't assert success — just confirm the flow finished.
|
||
console.print(" [dim]Signing in to Kimi (its login will open)…[/dim]")
|
||
harness_login(KIMI_KEY)
|
||
status = "kimi login flow finished — kimi stores its own credentials"
|
||
elif action == "logout":
|
||
console.print(" [dim]Signing out of Kimi…[/dim]")
|
||
harness_logout(KIMI_KEY)
|
||
status = "kimi logout flow finished"
|
||
elif action == "help":
|
||
_print_kimi_auth_help()
|
||
status = None
|
||
|
||
|
||
def _prompt_install_copilot() -> str | None:
|
||
"""Offer to install the missing ``copilot`` extra; return a status line.
|
||
|
||
Shown atop the Copilot drill-in when the optional-extra ``github-copilot-sdk``
|
||
is absent. Three-choice ``select`` like :func:`_prompt_install_cursor` /
|
||
:func:`_prompt_install_antigravity` (install now / set token anyway / show
|
||
command), and like them does NOT gate token management on the SDK: the
|
||
``copilot:`` token is stored independently and is useful once the SDK lands,
|
||
so declining falls through to the token menu. Install is portable and
|
||
index-free — see
|
||
:func:`omnigent.onboarding.copilot_auth.copilot_install_command`.
|
||
|
||
:returns: Status string for the drill-in's transient status line, or
|
||
``None`` (set-token-anyway / Esc / printed-command, no actionable result).
|
||
"""
|
||
from rich.markup import escape as _rich_escape
|
||
|
||
from omnigent.onboarding.copilot_auth import COPILOT_EXTRA, install_copilot_sdk
|
||
from omnigent.onboarding.extra_install import extra_install_display
|
||
from omnigent.onboarding.interactive import console, select
|
||
|
||
cmd = extra_install_display(COPILOT_EXTRA)
|
||
# ``select`` renders text through Rich markup; escape the literal
|
||
# ``[copilot]`` so it renders verbatim.
|
||
cmd_markup = _rich_escape(cmd)
|
||
choice = select(
|
||
"Copilot's SDK (github-copilot-sdk) isn't installed. Install it now?",
|
||
[
|
||
f"Install it now ({cmd_markup})",
|
||
"Set the GitHub token anyway",
|
||
"I'll run it myself (show the command)",
|
||
],
|
||
descriptions=[
|
||
f"Runs `{cmd_markup}`, then continues.",
|
||
"Skip the install — store the token now; the SDK can be added later.",
|
||
"Print the command so you can install it yourself, then continue.",
|
||
],
|
||
default=0,
|
||
clear_on_exit=True,
|
||
)
|
||
if choice == 0:
|
||
console.print(f" [dim]Installing the copilot extra — running `{cmd_markup}`…[/dim]")
|
||
if install_copilot_sdk():
|
||
console.print(" [green]✓ github-copilot-sdk installed[/green]")
|
||
return "✓ github-copilot-sdk installed"
|
||
console.print(f" [red]Install failed.[/red] Run it manually: [bold]{cmd_markup}[/bold]")
|
||
return "✗ Install failed — set the token anyway, or install by hand"
|
||
if choice < 0:
|
||
return _SOFT_INSTALL_ABORT
|
||
if choice == 2: # run it yourself
|
||
console.print(f" Install the copilot extra with:\n [bold]{cmd_markup}[/bold]")
|
||
return None
|
||
# choice == 1 (set token anyway): fall through to the token menu silently.
|
||
return None
|
||
|
||
|
||
def _manage_copilot_harness() -> None:
|
||
"""Run the level-2 loop for Copilot: manage its GitHub token.
|
||
|
||
Copilot runs via the ``github-copilot-sdk`` package and authenticates against
|
||
GitHub's Copilot backend with a GitHub token — the SDK requires one and it
|
||
has no provider/gateway family. So this manages exactly that credential:
|
||
set / replace / remove a token stored in the omnigent secret store, mirroring
|
||
how cursor / antigravity persist theirs (the secret in the store, a
|
||
``keychain:``/``env:`` reference in ``~/.omnigent/config.yaml``).
|
||
|
||
When the optional ``github-copilot-sdk`` is missing, the drill-in first
|
||
offers to install it (:func:`_prompt_install_copilot`). Unlike the CLI-backed
|
||
harnesses (which gate on the CLI), declining still drops into the token
|
||
menu — the ``copilot:`` token is independently storable. Mirrors cursor /
|
||
antigravity.
|
||
|
||
:returns: None. Side effects: may install the ``copilot`` extra, and may
|
||
write the ``copilot:`` block of ``~/.omnigent/config.yaml`` and the
|
||
secret store.
|
||
"""
|
||
from omnigent.onboarding import secrets as secret_store
|
||
from omnigent.onboarding.copilot_auth import (
|
||
COPILOT_CONFIG_KEY,
|
||
COPILOT_SECRET_NAME,
|
||
copilot_github_token_configured,
|
||
copilot_github_token_ref,
|
||
copilot_sdk_installed,
|
||
)
|
||
from omnigent.onboarding.interactive import select
|
||
|
||
# Offer the install once on entry (not per loop iteration) when the SDK is
|
||
# absent; the result seeds the menu's status line. Declining falls through
|
||
# to token management, since the token is SDK-independent.
|
||
status: str | None = None
|
||
if not copilot_sdk_installed():
|
||
status = _prompt_install_copilot()
|
||
if status == _SOFT_INSTALL_ABORT:
|
||
return
|
||
while True:
|
||
config = _load_global_config()
|
||
token_set = copilot_github_token_configured(config)
|
||
|
||
rows: list[_HarnessMenuRow] = [
|
||
_HarnessMenuRow(
|
||
"Replace GitHub token" if token_set else "Set GitHub token",
|
||
action="set_key",
|
||
)
|
||
]
|
||
if token_set:
|
||
rows.append(_HarnessMenuRow("Remove GitHub token", action="remove_key"))
|
||
rows.append(_HarnessMenuRow("← Back", action="back"))
|
||
|
||
header = (
|
||
"Copilot — GitHub token configured" if token_set else "Copilot — no GitHub token yet"
|
||
)
|
||
idx = select(header, [r.label for r in rows], clear_on_exit=True, status=status)
|
||
if idx < 0: # Esc / q
|
||
return
|
||
action = rows[idx].action
|
||
if action == "back":
|
||
return
|
||
if action == "set_key":
|
||
status = _set_copilot_github_token()
|
||
elif action == "remove_key":
|
||
ref = copilot_github_token_ref(config)
|
||
# Only the secret we own (``keychain:copilot``) is ours to delete: a
|
||
# hand-edited block may point at a shared ``keychain:<other>`` secret,
|
||
# and an ``env:`` ref names the user's own environment. In both of
|
||
# those cases just drop the config block and leave the secret.
|
||
if ref == f"keychain:{COPILOT_SECRET_NAME}":
|
||
secret_store.delete_secret(COPILOT_SECRET_NAME)
|
||
_save_global_config({}, unset_keys=(COPILOT_CONFIG_KEY,))
|
||
status = "✓ Removed Copilot GitHub token"
|
||
|
||
|
||
def _set_copilot_github_token() -> str | None:
|
||
"""Prompt for and store a Copilot GitHub token; return a status line.
|
||
|
||
Offers an existing ``COPILOT_GITHUB_TOKEN`` / ``GH_TOKEN`` / ``GITHUB_TOKEN``
|
||
first (recorded as an ``env:`` ref, so the secret stays in the environment),
|
||
else reads it with a hidden prompt and stores it under ``keychain:copilot``.
|
||
The token shape is checked softly (a classic ``ghp_`` PAT — which Copilot
|
||
rejects — or a wrong paste is flagged but can be forced). The token is never
|
||
echoed.
|
||
|
||
:returns: A status string for the menu, or ``None`` if the user aborted.
|
||
"""
|
||
from omnigent.onboarding import secrets as secret_store
|
||
from omnigent.onboarding.copilot_auth import (
|
||
COPILOT_SECRET_NAME,
|
||
COPILOT_TOKEN_ENV_VARS,
|
||
copilot_github_token_settings,
|
||
looks_like_github_copilot_token,
|
||
)
|
||
from omnigent.onboarding.interactive import prompt_text
|
||
|
||
detected_var = next((v for v in COPILOT_TOKEN_ENV_VARS if os.environ.get(v)), None)
|
||
if detected_var is not None and click.confirm(
|
||
f"Detected {detected_var} in the environment — use it?", default=True
|
||
):
|
||
detected = os.environ[detected_var]
|
||
if not looks_like_github_copilot_token(detected) and not click.confirm(
|
||
f"${detected_var} doesn't look like a Copilot-capable GitHub token "
|
||
"(github_pat_/gho_). Use it anyway?",
|
||
default=False,
|
||
):
|
||
return None
|
||
_save_global_config(copilot_github_token_settings(f"env:{detected_var}"))
|
||
return f"✓ Copilot GitHub token set (from ${detected_var})"
|
||
|
||
pasted = prompt_text("GitHub token with Copilot access", hide_input=True).strip()
|
||
if not pasted:
|
||
return None
|
||
if not looks_like_github_copilot_token(pasted) and not click.confirm(
|
||
"That doesn't look like a Copilot-capable GitHub token (github_pat_/gho_). "
|
||
"Store it anyway?",
|
||
default=False,
|
||
):
|
||
return None
|
||
secret_store.store_secret(COPILOT_SECRET_NAME, pasted)
|
||
_save_global_config(copilot_github_token_settings(f"keychain:{COPILOT_SECRET_NAME}"))
|
||
return "✓ Copilot GitHub token stored"
|
||
|
||
|
||
def _manage_credential(provider: str, family: str) -> str | None:
|
||
"""Run the level-3 loop for one credential: make default / remove.
|
||
|
||
Opened by selecting a credential at level 2. Offers ``Make default`` (only
|
||
when it is not already this harness's default), ``Remove``, and ``← Back``.
|
||
Make-default / remove return to level 2 with a confirmation; ``← Back`` /
|
||
Esc / ``q`` return with no change.
|
||
|
||
:param provider: The provider id of the chosen credential, e.g. ``"openai"``.
|
||
:param family: The harness surface in context, ``"anthropic"`` /
|
||
``"openai"`` / ``"pi"``.
|
||
:returns: A confirmation string to show as a transient status at level 2,
|
||
or ``None`` when nothing changed.
|
||
"""
|
||
from omnigent.onboarding.configure_models import family_label
|
||
from omnigent.onboarding.interactive import select
|
||
from omnigent.onboarding.provider_config import (
|
||
DATABRICKS_KIND,
|
||
SUBSCRIPTION_KIND,
|
||
load_providers,
|
||
surface_default_provider,
|
||
)
|
||
|
||
config = _load_global_config()
|
||
entry = load_providers(config).get(provider)
|
||
if entry is None:
|
||
return None
|
||
label = _family_credential_label(config, family, provider, entry)
|
||
rows: list[_HarnessMenuRow] = []
|
||
# "Make default" is offered unless this credential is already the
|
||
# surface's *effective* default (matching the ✓ on the level-2 row) —
|
||
# for pi that covers the fallback-driven default too, where offering
|
||
# "make default" would be a confusing no-op.
|
||
default = surface_default_provider(config, family)
|
||
if default is None or default.name != provider:
|
||
rows.append(
|
||
_HarnessMenuRow(
|
||
f"Make default for {family_label(family)}", action="set_default", provider=provider
|
||
)
|
||
)
|
||
rows.append(_HarnessMenuRow("Remove", action="remove", provider=provider))
|
||
rows.append(_HarnessMenuRow("← Back", action="back"))
|
||
|
||
idx = select(label, [r.label for r in rows], clear_on_exit=True)
|
||
if idx < 0: # Esc / q — back to the credential list, no change
|
||
return None
|
||
row = rows[idx]
|
||
if row.action == "back":
|
||
return None
|
||
if row.action == "set_default":
|
||
return _set_harness_default(provider, family)
|
||
# A subscription's credential lives in the harness CLI's own auth file, not
|
||
# our config — so removing it means signing out of that CLI (otherwise the
|
||
# login persists and ambient detection re-adopts it on the next open).
|
||
if entry.kind == SUBSCRIPTION_KIND:
|
||
return _remove_subscription(provider, family)
|
||
# A databricks provider was wired by `ucode configure`, which edits
|
||
# harness configs outside ~/.omnigent/config.yaml — so removing it
|
||
# also cleans those edits up (otherwise codex keeps routing through
|
||
# the workspace gateway).
|
||
if entry.kind == DATABRICKS_KIND:
|
||
return _remove_databricks_provider(provider)
|
||
return _remove_credential(provider)
|
||
|
||
|
||
def _remove_subscription(provider: str, family: str) -> str | None:
|
||
"""Sign out of the harness CLI and remove the subscription credential.
|
||
|
||
Unlike a key/gateway provider (whose credential is ours to drop), a
|
||
subscription is backed by the harness CLI's own login file
|
||
(``~/.codex/auth.json`` / ``~/.claude/.credentials.json``). Deleting only
|
||
our entry would leave that login in place — so it would still drive the
|
||
standalone CLI, and ambient detection would re-adopt the subscription on the
|
||
next ``configure`` open. So "remove" here runs the harness's own logout
|
||
(``codex logout`` / ``claude auth logout``) and then drops our entry. Guarded
|
||
by an explicit confirm (default No) because it signs the user out of the
|
||
standalone CLI too. (To merely stop *using* a subscription while staying
|
||
logged in, the user makes another provider the default instead.)
|
||
|
||
:param provider: The subscription provider id, e.g. ``"codex-subscription"``.
|
||
:param family: The harness family, ``"anthropic"`` (Claude) / ``"openai"``
|
||
(Codex).
|
||
:returns: A confirmation message for the level-2 status line, or ``None``
|
||
when the user declined (nothing changed). Side effects: runs the
|
||
harness logout command and writes ``~/.omnigent/config.yaml``.
|
||
"""
|
||
from omnigent.onboarding.harness_install import harness_install_spec, harness_logout
|
||
from omnigent.onboarding.interactive import select
|
||
|
||
spec = harness_install_spec(family)
|
||
disp = spec.display if spec is not None else family
|
||
logout_cmd = (
|
||
f"{spec.binary} {' '.join(spec.logout_args)}"
|
||
if spec is not None and spec.logout_args is not None
|
||
else "logout"
|
||
)
|
||
choice = select(
|
||
f"Remove {disp} subscription?",
|
||
[f"Yes — sign out of {disp} and remove", "No — keep it"],
|
||
descriptions=[
|
||
f"Runs `{logout_cmd}`, signing you out of the standalone {disp} CLI "
|
||
"too, then removes it here.",
|
||
f"Leave the subscription and your {disp} login untouched.",
|
||
],
|
||
default=1, # default to the non-destructive choice
|
||
clear_on_exit=True,
|
||
)
|
||
if choice != 0:
|
||
return None
|
||
signed_out = harness_logout(family)
|
||
# Drop our entry regardless — the user asked to remove it. If logout failed
|
||
# we say so, since the standalone login may persist (and be re-detected).
|
||
_remove_credential(provider)
|
||
if signed_out:
|
||
return f"✓ Signed out of {disp} and removed"
|
||
return (
|
||
f"✓ Removed {disp} subscription — note: `{logout_cmd}` did not complete, "
|
||
f"so you may still be signed in to the {disp} CLI"
|
||
)
|
||
|
||
|
||
def _remove_databricks_provider(provider: str) -> str:
|
||
"""Remove a databricks provider and clean up ucode's harness wiring.
|
||
|
||
A ``kind: databricks`` provider was wired by running ``ucode configure``
|
||
(the add flow), which writes harness configs *outside*
|
||
``~/.omnigent/config.yaml`` — most damagingly, for Codex < 0.134.0 it
|
||
rewrites the user's real ``~/.codex/config.toml`` (top-level
|
||
``profile = "ucode"``) so even the bare ``codex`` CLI routes through the
|
||
workspace gateway, and ``ucode revert`` does not undo that edit. Removing
|
||
the provider therefore undoes that wiring as part of the removal — no
|
||
extra confirm, matching how a key provider's ``Remove`` acts immediately.
|
||
The cleanup only ever touches ucode-namespaced artifacts (the ``profile``
|
||
selector only when it equals ``"ucode"``; see
|
||
:mod:`omnigent.onboarding.ucode_cleanup`), so the user's own settings
|
||
are never at risk. Removal applies to every harness the provider
|
||
serves — a databricks entry routes both Claude and Codex.
|
||
|
||
:param provider: The databricks provider id, e.g. ``"databricks"``.
|
||
:returns: A confirmation message for the level-2 status line reporting
|
||
the removal and what wiring was cleaned (nothing extra is appended
|
||
when no ucode wiring existed). Side effects: may edit
|
||
``~/.codex/config.toml``, delete ucode sidecar files, run
|
||
``claude mcp remove``, and write ``~/.omnigent/config.yaml``.
|
||
"""
|
||
from omnigent.errors import OmnigentError
|
||
from omnigent.onboarding.ucode_cleanup import remove_ucode_wiring
|
||
|
||
cleanup_note = ""
|
||
try:
|
||
removal = remove_ucode_wiring()
|
||
except (OmnigentError, OSError) as exc:
|
||
# The entry removal below still proceeds — the user asked for it —
|
||
# but say exactly what was left behind instead of failing silently.
|
||
cleanup_note = f" — ucode cleanup incomplete: {exc}"
|
||
else:
|
||
cleaned: list[str] = []
|
||
if removal.codex_config_stripped:
|
||
cleaned.append("cleaned ~/.codex/config.toml")
|
||
if removal.removed_sidecars:
|
||
cleaned.append(f"deleted {len(removal.removed_sidecars)} ucode sidecar file(s)")
|
||
if removal.web_search_mcp_removed:
|
||
cleaned.append("unregistered ucode's web_search MCP")
|
||
if cleaned:
|
||
cleanup_note = f" — {', '.join(cleaned)}"
|
||
removed_msg = _remove_credential(provider) or f"✓ Removed {provider}"
|
||
return f"{removed_msg}{cleanup_note}"
|
||
|
||
|
||
def _set_harness_default(provider: str, family: str) -> str | None:
|
||
"""Make *provider* the default for *family* and persist wholesale.
|
||
|
||
:param provider: The provider name to default, e.g. ``"openrouter"``.
|
||
:param family: The harness surface to scope the default to,
|
||
``"anthropic"``, ``"openai"``, or ``"pi"`` — leaving the other
|
||
harnesses' defaults untouched.
|
||
:returns: A confirmation message for the caller to show as a transient
|
||
status, or ``None`` when there was nothing to do. Side effect:
|
||
writes ``~/.omnigent/config.yaml``.
|
||
"""
|
||
from omnigent.onboarding.configure_models import family_label
|
||
from omnigent.onboarding.provider_config import load_providers, set_default_provider
|
||
|
||
block = _load_global_config().get("providers")
|
||
if not isinstance(block, dict):
|
||
return None
|
||
entry = load_providers({"providers": block}).get(provider)
|
||
label = _credential_label(provider, entry) if entry is not None else provider
|
||
_save_global_config({"providers": set_default_provider(block, provider, family)})
|
||
return f"✓ {label} is now the {family_label(family)} default"
|
||
|
||
|
||
def _clear_detection_dismissal(name: str) -> None:
|
||
"""Drop *name* from the persisted ``dismissed_detections`` list, if present.
|
||
|
||
Called when the user explicitly re-adds a previously Removed (and thus
|
||
dismissed) ambient credential — e.g. picking the detected codex
|
||
config.toml provider from the add menu — so the detection behaves like
|
||
an ordinary one again.
|
||
|
||
:param name: The detection name to un-dismiss, e.g. ``"codex-databricks"``.
|
||
:returns: None. Side effect: writes ``~/.omnigent/config.yaml`` when the
|
||
name was dismissed; no write otherwise.
|
||
"""
|
||
from omnigent.onboarding.detected import (
|
||
DISMISSED_DETECTIONS_KEY,
|
||
dismissed_detection_names,
|
||
)
|
||
|
||
dismissed = dismissed_detection_names(_load_global_config())
|
||
if name not in dismissed:
|
||
return
|
||
_save_global_config({DISMISSED_DETECTIONS_KEY: sorted(dismissed - {name})})
|
||
|
||
|
||
def _remove_credential(provider: str) -> str | None:
|
||
"""Remove the *provider* credential and persist wholesale.
|
||
|
||
The stored secret (if any) is left in place — removing a credential does
|
||
not assume its key is unwanted.
|
||
|
||
:param provider: The provider id to remove, e.g. ``"openrouter"``.
|
||
:returns: A confirmation message for the caller to show as a transient
|
||
status, or ``None`` when there was nothing to remove. Side effect:
|
||
writes ``~/.omnigent/config.yaml`` (and, when the removed entry is
|
||
backed by a live ambient detection that cannot be signed out,
|
||
records its name under ``dismissed_detections`` so the next
|
||
configure open does not silently re-adopt it).
|
||
"""
|
||
from omnigent.onboarding.ambient import detect_providers
|
||
from omnigent.onboarding.detected import (
|
||
DISMISSED_DETECTIONS_KEY,
|
||
dismissed_detection_names,
|
||
)
|
||
from omnigent.onboarding.provider_config import load_providers
|
||
|
||
config = _load_global_config()
|
||
block = config.get("providers")
|
||
if not isinstance(block, dict) or provider not in block:
|
||
return None
|
||
entry = load_providers({"providers": block}).get(provider)
|
||
label = _credential_label(provider, entry) if entry is not None else provider
|
||
remaining = {k: v for k, v in block.items() if k != provider}
|
||
settings: dict[str, Any] = {"providers": remaining} # type: ignore[explicit-any] # yaml-boundary mapping
|
||
# If a live ambient detection backs this entry, removing the entry alone
|
||
# is a no-op: the next configure open re-detects and re-adopts it (the
|
||
# "Remove doesn't remove" bug). Subscriptions are exempt — their removal
|
||
# path signs out of the CLI instead, and a future re-login SHOULD
|
||
# re-adopt. Everything else (env API key, codex config.toml provider,
|
||
# local Ollama) gets a persisted dismissal that the add menu's detected
|
||
# option clears on re-add.
|
||
backing = next(
|
||
(d for d in detect_providers() if d.name == provider and d.kind != "subscription"),
|
||
None,
|
||
)
|
||
if backing is not None:
|
||
settings[DISMISSED_DETECTIONS_KEY] = sorted(dismissed_detection_names(config) | {provider})
|
||
_save_global_config(settings) # wholesale replace per key
|
||
if backing is not None:
|
||
return f"✓ Removed {label} — it stays on your machine but won't be auto-configured again"
|
||
return f"✓ Removed {label}"
|
||
|
||
|
||
def _launch_opencode_auth_login() -> str | None:
|
||
"""Launch interactive ``opencode auth login``; return a post-login status.
|
||
|
||
``opencode auth login`` is interactive (pick a provider, sign in), so this
|
||
hands the terminal to ``opencode`` and re-reads the credential state on
|
||
return. Mirrors :func:`_launch_goose_configure`.
|
||
"""
|
||
from omnigent.onboarding.harness_install import (
|
||
OPENCODE_KEY,
|
||
harness_cli_installed,
|
||
harness_install_spec,
|
||
)
|
||
from omnigent.onboarding.interactive import console
|
||
from omnigent.onboarding.opencode_auth import opencode_auth_summary
|
||
|
||
if not harness_cli_installed(OPENCODE_KEY):
|
||
return "✗ opencode CLI not found"
|
||
spec = harness_install_spec(OPENCODE_KEY)
|
||
assert spec is not None
|
||
console.print(
|
||
" [dim]Launching [bold]opencode auth login[/bold] — pick a provider and "
|
||
"sign in, then return.[/dim]"
|
||
)
|
||
with contextlib.suppress(OSError, KeyboardInterrupt):
|
||
subprocess.run([spec.binary, "auth", "login"], check=False)
|
||
summary = opencode_auth_summary()
|
||
if summary.has_provider:
|
||
return f"✓ providers: {summary.describe()}"
|
||
return "No provider detected yet"
|
||
|
||
|
||
def _run_opencode_auth_list() -> None:
|
||
"""Show ``opencode auth list`` (stored credentials + detected env providers)."""
|
||
from omnigent.onboarding.harness_install import OPENCODE_KEY, harness_install_spec
|
||
|
||
spec = harness_install_spec(OPENCODE_KEY)
|
||
if spec is None:
|
||
return
|
||
with contextlib.suppress(OSError, KeyboardInterrupt):
|
||
subprocess.run([spec.binary, "auth", "list"], check=False)
|
||
|
||
|
||
def _list_opencode_models() -> list[str]:
|
||
"""Return the ``provider/model`` ids OpenCode can launch (``opencode models``).
|
||
|
||
Best-effort: an absent CLI or a failed/empty invocation yields ``[]`` (the
|
||
caller then tells the user to sign a provider in first).
|
||
"""
|
||
from omnigent.onboarding.harness_install import OPENCODE_KEY, harness_install_spec
|
||
|
||
spec = harness_install_spec(OPENCODE_KEY)
|
||
if spec is None:
|
||
return []
|
||
try:
|
||
result = subprocess.run(
|
||
[spec.binary, "models"],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=30,
|
||
check=False,
|
||
)
|
||
except (OSError, subprocess.SubprocessError):
|
||
return []
|
||
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
||
|
||
|
||
def _set_opencode_default_model(current: str | None) -> str | None:
|
||
"""Pick OpenCode's default model and persist it as ``opencode_model``.
|
||
|
||
The choice is what ``omni opencode`` launches on when no ``--model`` is
|
||
given — written into the per-session ``opencode.json`` at spawn so the TUI
|
||
starts on it instead of ``opencode/big-pickle``. Returns a status line for
|
||
the drill-in, or ``None`` when cancelled.
|
||
|
||
:param current: The currently-persisted default model, or ``None``.
|
||
"""
|
||
from omnigent.onboarding.interactive import console, select
|
||
from omnigent.onboarding.opencode_auth import reachable_provider_ids
|
||
|
||
models = _list_opencode_models()
|
||
if not models:
|
||
return "✗ no models — sign in to a provider first (opencode auth login)"
|
||
# `opencode models` can list hundreds of `provider/model` ids across every
|
||
# provider on models.dev — too long for the picker (it overflows the
|
||
# viewport and flickers). Narrow to the providers the user can actually
|
||
# authenticate (stored auth.json + env keys); fall back to the full list
|
||
# only if that filter would hide everything.
|
||
reachable = reachable_provider_ids()
|
||
if reachable:
|
||
scoped = [m for m in models if m.split("/", 1)[0] in reachable]
|
||
models = scoped or models
|
||
options = list(models)
|
||
clear_index = -1
|
||
if current is not None:
|
||
clear_index = len(options)
|
||
options.append("Clear default (use OpenCode's own default)")
|
||
default = models.index(current) if current in models else 0
|
||
# Even filtered to reachable providers the list can exceed the screen, so
|
||
# bound the picker to a scrolling viewport sized to the terminal (leaving
|
||
# room for the title / status / footer / "N more" markers).
|
||
rows = shutil.get_terminal_size(fallback=(80, 24)).lines
|
||
idx = select(
|
||
"Pick OpenCode's default model",
|
||
options,
|
||
default=default,
|
||
clear_on_exit=True,
|
||
status=f"current: {current}" if current else None,
|
||
max_visible=max(5, rows - 8),
|
||
)
|
||
if idx < 0:
|
||
return None
|
||
if idx == clear_index:
|
||
_save_global_config({}, unset_keys=("opencode_model",))
|
||
console.print(" [green]✓ default model cleared[/green]")
|
||
return "✓ default model cleared"
|
||
chosen = models[idx]
|
||
_save_global_config({"opencode_model": chosen})
|
||
console.print(f" [green]✓ default model set to[/green] [bold]{chosen}[/bold]")
|
||
return f"✓ default model: {chosen}"
|
||
|
||
|
||
def _print_opencode_auth_help() -> None:
|
||
"""Explain where OpenCode's model credentials come from."""
|
||
from omnigent.onboarding.interactive import console
|
||
|
||
console.print(
|
||
" OpenCode resolves a model from the provider its agent uses:\n"
|
||
" • [bold]opencode auth login[/bold] — sign in to a provider (OpenAI, Anthropic, …);\n"
|
||
" stored in ~/.local/share/opencode/auth.json.\n"
|
||
" • Provider env vars (OPENAI_API_KEY / ANTHROPIC_API_KEY / …) are auto-detected.\n"
|
||
" • Databricks gateway: set an agent ``profile`` (configured under Claude / Codex);\n"
|
||
" Omnigent synthesizes opencode's per-session provider config from it.\n"
|
||
" Omnigent stores no OpenCode credential of its own.\n"
|
||
" [dim]Tip:[/dim] 'Set default model' picks which model `omni opencode` launches on\n"
|
||
" (otherwise OpenCode uses its built-in default, opencode/big-pickle)."
|
||
)
|
||
|
||
|
||
def _manage_opencode_harness() -> None:
|
||
"""Run the level-2 drill-in for OpenCode: ensure the CLI, then manage providers.
|
||
|
||
OpenCode owns its own provider auth — ``opencode auth login`` (stored in
|
||
``~/.local/share/opencode/auth.json``) or ambient provider env vars — so,
|
||
like the Goose / Qwen drill-ins, this reports which providers OpenCode can
|
||
reach and offers to launch its native login; it never stores a key through
|
||
Omnigent. (For the Databricks-gateway path the agent's ``profile`` is
|
||
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.
|
||
|
||
:returns: None. Side effect: may ``npm install`` the opencode CLI.
|
||
"""
|
||
from omnigent.onboarding.harness_install import (
|
||
OPENCODE_KEY,
|
||
harness_cli_installed,
|
||
harness_install_command,
|
||
install_harness_cli,
|
||
)
|
||
from omnigent.onboarding.interactive import console, select
|
||
|
||
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?",
|
||
[
|
||
f"Yes — install ({cmd})",
|
||
"No — back to harnesses",
|
||
"I'll run it myself (show the command)",
|
||
],
|
||
descriptions=[
|
||
f"Runs `{cmd}` (needs npm).",
|
||
"Return to the harness picker without installing.",
|
||
"Print the command so you can install it yourself, then return.",
|
||
],
|
||
default=0,
|
||
clear_on_exit=True,
|
||
)
|
||
if choice == 0:
|
||
console.print(f" [dim]Installing OpenCode — running `{cmd}`…[/dim]")
|
||
if install_harness_cli(OPENCODE_KEY):
|
||
console.print(" [green]✓ OpenCode installed[/green]")
|
||
else:
|
||
console.print(
|
||
f" [red]Install failed.[/red] Run it manually, then re-open: "
|
||
f"[bold]{cmd}[/bold]"
|
||
)
|
||
return
|
||
elif choice == 2: # run it yourself
|
||
console.print(f" Install OpenCode with:\n [bold]{cmd}[/bold]")
|
||
return
|
||
else:
|
||
return
|
||
|
||
# OpenCode owns its provider auth (``opencode auth login`` → auth.json) or
|
||
# ambient env keys; Omnigent stores nothing. Report what's reachable and
|
||
# offer to run its native login — like the Goose/Qwen drill-ins.
|
||
status: str | None = None
|
||
while True:
|
||
from omnigent.onboarding.opencode_auth import opencode_auth_summary
|
||
|
||
summary = opencode_auth_summary()
|
||
default_model = _load_effective_config().get("opencode_model")
|
||
header = (
|
||
f"OpenCode — providers: {summary.describe()}"
|
||
if summary.has_provider
|
||
else "OpenCode — no provider configured yet"
|
||
)
|
||
model_label = (
|
||
f"Set default model (current: {default_model})"
|
||
if default_model
|
||
else "Set default model"
|
||
)
|
||
rows: list[_HarnessMenuRow] = [
|
||
_HarnessMenuRow("Run opencode auth login", action="login"),
|
||
_HarnessMenuRow(model_label, action="model"),
|
||
_HarnessMenuRow("List providers & credentials", action="list"),
|
||
_HarnessMenuRow("Show provider options", action="help"),
|
||
_HarnessMenuRow("← Back", action="back"),
|
||
]
|
||
idx = select(header, [r.label for r in rows], clear_on_exit=True, status=status)
|
||
if idx < 0: # Esc / q
|
||
return
|
||
action = rows[idx].action
|
||
if action == "back":
|
||
return
|
||
if action == "login":
|
||
status = _launch_opencode_auth_login()
|
||
elif action == "model":
|
||
status = _set_opencode_default_model(default_model)
|
||
elif action == "list":
|
||
_run_opencode_auth_list()
|
||
status = None
|
||
elif action == "help":
|
||
_print_opencode_auth_help()
|
||
status = None
|
||
|
||
|
||
def _run_configure_harnesses_interactive() -> None:
|
||
"""Run the interactive model/credential three-level picker.
|
||
|
||
Invoked by ``omnigent setup --no-internal-beta`` and the bare-``run``
|
||
first-run path, so both drive the identical flow.
|
||
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. 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" / "Not configured")
|
||
— 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.cells import cell_len
|
||
from rich.markup import escape
|
||
|
||
from omnigent.onboarding.antigravity_auth import (
|
||
ANTIGRAVITY_ENV_VARS,
|
||
ANTIGRAVITY_EXTRA,
|
||
antigravity_api_key_configured,
|
||
antigravity_sdk_installed,
|
||
)
|
||
from omnigent.onboarding.configure_models import family_label
|
||
from omnigent.onboarding.copilot_auth import (
|
||
COPILOT_EXTRA,
|
||
COPILOT_TOKEN_ENV_VARS,
|
||
copilot_github_token_configured,
|
||
copilot_sdk_installed,
|
||
)
|
||
from omnigent.onboarding.cursor_auth import (
|
||
CURSOR_EXTRA,
|
||
cursor_api_key_configured,
|
||
cursor_sdk_installed,
|
||
)
|
||
from omnigent.onboarding.extra_install import extra_install_display
|
||
from omnigent.onboarding.goose_auth import goose_config_summary
|
||
from omnigent.onboarding.harness_install import (
|
||
COPILOT_KEY,
|
||
CURSOR_KEY,
|
||
GOOSE_KEY,
|
||
HERMES_KEY,
|
||
KIMI_KEY,
|
||
KIRO_KEY,
|
||
OPENCODE_KEY,
|
||
QWEN_KEY,
|
||
harness_cli_installed,
|
||
harness_install_command,
|
||
harness_install_spec,
|
||
)
|
||
from omnigent.onboarding.interactive import select
|
||
from omnigent.onboarding.provider_config import (
|
||
ANTHROPIC_FAMILY,
|
||
OPENAI_FAMILY,
|
||
PI_SURFACE,
|
||
surface_default_provider,
|
||
)
|
||
|
||
# Surface missing external tooling (Node ≥22.10 / tmux) the harnesses need,
|
||
# once up front, so configuring a credential doesn't lead to a cryptic
|
||
# failure when the harness later can't launch.
|
||
_warn_missing_harness_dependencies()
|
||
|
||
# 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 status
|
||
# line); newly-adopted machine credentials get a one-time callout naming
|
||
# 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
|
||
# it (skipped by ↑/↓). Drilling in (level 2) keeps add/manage off this
|
||
# overview. The menu clears in place on each choice so the session stays on
|
||
# one screen. Quit / Esc / q exits.
|
||
_QUIT = "\x00quit" # sentinel marking the Quit row (not a family)
|
||
# Sentinel marking the Antigravity row — it is not a provider family (Gemini
|
||
# is outside the anthropic/openai machinery), so it dispatches to its own
|
||
# credential manager rather than ``_manage_harness_providers``.
|
||
_ANTIGRAVITY = "\x00antigravity"
|
||
# Sentinel marking the Qwen Code row — like Antigravity/Cursor it is not a
|
||
# provider family (its v1 auth is the CLI's own env vars / ``/auth`` flow,
|
||
# not an Omnigent credential), so it dispatches to its own drill-in.
|
||
_QWEN = "\x00qwen"
|
||
# Sentinel marking the OpenCode row — native-server harness with no Omnigent
|
||
# credential of its own (it routes through the bound agent's Databricks
|
||
# gateway profile or ambient provider env), so it dispatches to its own
|
||
# binary-install/info drill-in.
|
||
_OPENCODE = "\x00opencode"
|
||
# Sentinel marking the Goose row — like Qwen/Antigravity/Cursor it is not a
|
||
# provider family (Goose owns its own auth via ``goose configure``, not an
|
||
# Omnigent credential), so it dispatches to its own drill-in.
|
||
_GOOSE = "\x00goose"
|
||
# Sentinel marking the Hermes row — like Goose it owns its own auth via
|
||
# ``hermes model`` and is installed via a curl installer.
|
||
_HERMES = "\x00hermes"
|
||
# Sentinel marking the Kiro row — like Goose/Hermes it owns its own auth (via
|
||
# ``kiro-cli login``) and is installed via Kiro's curl installer, so it
|
||
# dispatches to its own drill-in rather than a provider family.
|
||
_KIRO = "\x00kiro"
|
||
# Sentinel marking the Kimi Code row — like Cursor/Antigravity/Qwen it is
|
||
# not a provider family. Auth lives entirely in the kimi CLI (``kimi login``
|
||
# / ``kimi provider add`` → ~/.kimi/config.toml), so it dispatches to its
|
||
# own drill-in rather than ``_manage_harness_providers``.
|
||
_KIMI = "\x00kimi"
|
||
# Sentinels for the generic-ACP rows. Each configured agent gets its own row
|
||
# (``_ACP_AGENT_PREFIX + slug`` → per-agent remove drill-in); a single
|
||
# ``_ACP_ADD`` row jumps straight into the add flow. Not a provider family —
|
||
# each ACP agent owns its own auth.
|
||
_ACP_ADD = "\x00acp-add"
|
||
_ACP_AGENT_PREFIX = "\x00acp-agent:"
|
||
families = [ANTHROPIC_FAMILY, OPENAI_FAMILY, PI_SURFACE]
|
||
|
||
# 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); "action" is a do-something row (e.g. Add) with no status glyph. 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"),
|
||
"action": ("", "cyan"),
|
||
}
|
||
|
||
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 _truncate_cells(text: str, max_cells: int) -> str:
|
||
"""Truncate *text* to a terminal-cell budget, adding an ellipsis if needed."""
|
||
if cell_len(text) <= max_cells:
|
||
return text
|
||
ellipsis = "…"
|
||
budget = max(0, max_cells - cell_len(ellipsis))
|
||
out: list[str] = []
|
||
used = 0
|
||
for ch in text:
|
||
width = cell_len(ch)
|
||
if used + width > budget:
|
||
break
|
||
out.append(ch)
|
||
used += width
|
||
return "".join(out) + ellipsis
|
||
|
||
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))),
|
||
)
|
||
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", "")
|
||
|
||
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.hermes_auth import hermes_config_summary
|
||
from omnigent.onboarding.opencode_auth import opencode_auth_summary
|
||
|
||
rows: list[tuple[str, str, str, str, str]] = []
|
||
rows.append(_family_row(ANTHROPIC_FAMILY))
|
||
rows.append(_family_row(OPENAI_FAMILY))
|
||
|
||
# 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(extra_install_display(CURSOR_EXTRA)),
|
||
),
|
||
)
|
||
else:
|
||
rows.append(
|
||
(
|
||
CURSOR_KEY,
|
||
"Cursor",
|
||
"Not configured",
|
||
"warn",
|
||
"Open to add the Cursor API key.",
|
||
),
|
||
)
|
||
|
||
# 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; its provider/model live in
|
||
# ``~/.hermes/config.yaml`` (written by `hermes model`). Read that so a
|
||
# configured Hermes shows the picked model as ready, instead of always
|
||
# reading "not configured" on an installed binary. A fresh install
|
||
# ships ``provider: auto`` (nothing picked), so it still reads
|
||
# "not configured" until `hermes model` selects a concrete provider.
|
||
hermes = hermes_config_summary()
|
||
if not hermes.installed:
|
||
hermes_spec = harness_install_spec(HERMES_KEY)
|
||
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"
|
||
)
|
||
rows.append(
|
||
(_HERMES, "Hermes", "Not installed", "missing", _install_hint(hermes_hint)),
|
||
)
|
||
elif hermes.ready:
|
||
rows.append((_HERMES, "Hermes", hermes.describe(), "ready", ""))
|
||
else:
|
||
rows.append(
|
||
(
|
||
_HERMES,
|
||
"Hermes",
|
||
"Not configured",
|
||
"warn",
|
||
"Open to configure with `hermes model`.",
|
||
),
|
||
)
|
||
|
||
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(extra_install_display(ANTIGRAVITY_EXTRA)),
|
||
),
|
||
)
|
||
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(extra_install_display(COPILOT_EXTRA)),
|
||
),
|
||
)
|
||
else:
|
||
rows.append(
|
||
(
|
||
COPILOT_KEY,
|
||
"Copilot",
|
||
"Not configured",
|
||
"warn",
|
||
"Open to add the GitHub token.",
|
||
),
|
||
)
|
||
|
||
# Kiro — native CLI, own auth via `kiro-cli login`; there is no
|
||
# reliable local status probe, so an installed binary is still only
|
||
# "not configured" until the user signs in.
|
||
if harness_cli_installed(KIRO_KEY):
|
||
rows.append(
|
||
(_KIRO, "Kiro", "Not configured", "warn", "Sign in with `kiro-cli login`.")
|
||
)
|
||
else:
|
||
kiro_spec = harness_install_spec(KIRO_KEY)
|
||
kiro_hint = (
|
||
kiro_spec.install_hint
|
||
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)))
|
||
|
||
# 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
|
||
# install_hint when absent and show "not configured" when present.
|
||
if harness_cli_installed(KIMI_KEY):
|
||
rows.append(
|
||
(_KIMI, "Kimi Code", "Not configured", "warn", "Sign in with `kimi login`.")
|
||
)
|
||
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)))
|
||
|
||
# Custom ACP agents — the generic `acp` harness driving any user-configured
|
||
# ACP-agent command. Each configured agent gets its own overview row
|
||
# (select → per-agent remove drill-in) so it sits alongside the built-in
|
||
# harnesses, followed by an "Add" row that jumps straight into the add
|
||
# flow. Not gated on a binary — each agent owns its own install.
|
||
from omnigent.onboarding.acp_auth import acp_config_summary
|
||
|
||
acp_summary = acp_config_summary()
|
||
for agent in acp_summary.agents:
|
||
rows.append(
|
||
(
|
||
_ACP_AGENT_PREFIX + agent.slug,
|
||
agent.name,
|
||
f"ACP · {agent.command}",
|
||
"ready",
|
||
"Select to remove this ACP agent.",
|
||
)
|
||
)
|
||
rows.append(
|
||
(
|
||
_ACP_ADD,
|
||
"Add custom ACP agent" if acp_summary.configured else "Custom ACP agent",
|
||
"" if acp_summary.configured else "None configured",
|
||
"action",
|
||
"Add an ACP agent (gemini, qwen, goose, …).",
|
||
)
|
||
)
|
||
return rows
|
||
|
||
while True:
|
||
config = _load_global_config()
|
||
harness_rows = build_harness_rows()
|
||
# Place the status in a single column a fixed gutter right of the names,
|
||
# so every ✓/✗ glyph lines up vertically (the earlier 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
|
||
term_width = max(40, shutil.get_terminal_size(fallback=(80, 24)).columns)
|
||
# _render_menu prefixes selected rows with ``" ❯ "`` (7 cells).
|
||
# Cap the status text from the actual terminal width so verbose status
|
||
# rows (e.g. OpenCode's provider summary) do not wrap in the compact
|
||
# single-line overview.
|
||
max_status_width = max(8, min(30, term_width - 7 - name_col - len("✓ ")))
|
||
options: list[str] = []
|
||
selectable: list[bool] = []
|
||
row_target: list[str | None] = []
|
||
descriptions: list[str] = []
|
||
for target, name, status_text, kind, desc in harness_rows:
|
||
status_text = _truncate_cells(status_text, max_status_width)
|
||
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
|
||
target = row_target[idx]
|
||
if target == CURSOR_KEY:
|
||
_manage_cursor_harness()
|
||
elif target == COPILOT_KEY:
|
||
_manage_copilot_harness()
|
||
elif target in families:
|
||
_manage_harness_providers(target)
|
||
elif target == _ANTIGRAVITY:
|
||
_manage_antigravity_harness()
|
||
elif target == _QWEN:
|
||
_manage_qwen_harness()
|
||
elif target == _OPENCODE:
|
||
_manage_opencode_harness()
|
||
elif target == _GOOSE:
|
||
_manage_goose_harness()
|
||
elif target == _ACP_ADD:
|
||
_add_acp_agent()
|
||
elif isinstance(target, str) and target.startswith(_ACP_AGENT_PREFIX):
|
||
_manage_acp_agent(target[len(_ACP_AGENT_PREFIX) :])
|
||
elif target == _HERMES:
|
||
_manage_hermes_harness()
|
||
elif target == _KIRO:
|
||
_manage_kiro_harness()
|
||
elif target == _KIMI:
|
||
_manage_kimi_harness()
|
||
else: # Quit row (or, defensively, a non-family row)
|
||
return
|
||
|
||
|
||
@cli.command("setup")
|
||
@click.option(
|
||
"--internal-beta/--no-internal-beta",
|
||
default=False,
|
||
help="Run the standard model/credential setup (default): choose a "
|
||
"provider for each harness and set your defaults. Pass --internal-beta "
|
||
"to configure Databricks internal-beta defaults and authentication.",
|
||
)
|
||
def setup(internal_beta: bool) -> None:
|
||
"""
|
||
Launch the Omnigent first-time setup flow.
|
||
|
||
By default this runs the standard model/credential picker — choose a
|
||
provider for each harness and set your defaults, then start a session
|
||
with ``omnigent run``. (List configured credentials with
|
||
``omnigent config list``.) Pass ``--internal-beta`` to configure
|
||
Databricks internal-beta defaults and authentication instead.
|
||
"""
|
||
from omnigent.inner import ui
|
||
|
||
# Brand the first-run experience without pushing the actual picker below a
|
||
# typical 80×24 terminal. The full lockup is great in roomy terminals, but
|
||
# on short terminals it combines with the missing-tool warning and scrolls
|
||
# the menu off the first screen.
|
||
if shutil.get_terminal_size(fallback=(80, 24)).lines >= 32:
|
||
ui.print_landing(tagline="all your agents, one cli")
|
||
else:
|
||
ui.print_brandmark("setup")
|
||
|
||
if internal_beta:
|
||
# The internal-beta workspace defaults are excluded from the public OSS
|
||
# build. Fail loud with a clear message instead of an ImportError deep
|
||
# in the onboarding flow when someone passes --internal-beta there.
|
||
try:
|
||
import omnigent.onboarding.internal_beta # noqa: F401
|
||
except ImportError:
|
||
raise click.ClickException(
|
||
"Databricks internal-beta setup is not available in this build. "
|
||
"Run `omnigent setup` for the standard model/credential setup."
|
||
) from None
|
||
# Internal-beta routing mints workspace OAuth tokens via
|
||
# databricks-sdk at runtime, and the SDK ships in the `databricks`
|
||
# extra rather than the default install. Fail loud up front instead
|
||
# of completing the whole login flow and breaking on the first turn.
|
||
from omnigent.onboarding.databricks_config import (
|
||
DATABRICKS_EXTRA_INSTALL_HINT,
|
||
databricks_sdk_installed,
|
||
)
|
||
|
||
if not databricks_sdk_installed():
|
||
raise click.ClickException(
|
||
"Databricks internal-beta setup needs the databricks extra "
|
||
f"(databricks-sdk). Reinstall with:\n {DATABRICKS_EXTRA_INSTALL_HINT}"
|
||
)
|
||
# Surface missing external tooling (Node, tmux) before the Databricks
|
||
# bootstrap so a fresh machine sees every gap at once.
|
||
_warn_missing_harness_dependencies()
|
||
from omnigent.onboarding.internal_beta import _INTERNAL_BETA_DEFAULT_SERVER
|
||
from omnigent.onboarding.sandboxes.lakebox import install_demo_databricks_cli
|
||
from omnigent.onboarding.setup import run_onboarding
|
||
|
||
# Install the demo `databricks` CLI (with the `lakebox`
|
||
# subcommand) BEFORE profile onboarding — `run_onboarding`
|
||
# shells out to `databricks auth login`, and a fresh machine
|
||
# might not have the binary on PATH at all. Idempotent: skips
|
||
# the installer when the demo CLI is already present, but
|
||
# still persists ~/.local/bin in the user's shell rc files.
|
||
install_demo_databricks_cli()
|
||
with _isolated_databricks_cfg():
|
||
if not run_onboarding():
|
||
raise click.ClickException("onboarding did not complete; see output above.")
|
||
_run_configure_databricks()
|
||
agent_path = _materialize_internal_beta_agents()
|
||
_save_global_config(
|
||
{
|
||
"default_agent": str(agent_path),
|
||
"profile": "oss",
|
||
"server": _INTERNAL_BETA_DEFAULT_SERVER,
|
||
# auth: block provides the default executor credentials for
|
||
# agents that do not declare executor.auth themselves.
|
||
"auth": {"type": "databricks", "profile": "oss"},
|
||
}
|
||
)
|
||
click.echo(f"Set default_agent={agent_path} in {_GLOBAL_CONFIG_PATH}")
|
||
click.echo("Type `omnigent claude` to get started with Claude Code on omnigent.")
|
||
return
|
||
|
||
# --no-internal-beta: the standard model/credential picker. It warns
|
||
# about missing Node/tmux itself, configures providers/defaults, and
|
||
# returns; the user then starts a session with ``omnigent run``.
|
||
_run_configure_harnesses_interactive()
|
||
|
||
|
||
# ─── sandbox group ────────────────────────────────────────────────
|
||
# The provider-agnostic sandbox CLI lives in omnigent/cli_sandbox.py.
|
||
# Provider launcher modules are optional and may be absent from a given
|
||
# distribution; hide the group when none are available.
|
||
# `omnigent lakebox` is kept as an alias for `omnigent sandbox …
|
||
# --provider lakebox`, registered only when the lakebox provider ships.
|
||
if _sandbox_providers():
|
||
cli.add_command(_sandbox_group)
|
||
if "lakebox" in _sandbox_providers():
|
||
cli.add_command(_lakebox_alias_group)
|
||
|
||
# ─── debug group ──────────────────────────────────────────────────
|
||
#
|
||
# Operator-only maintenance commands, grouped under ``omnigent debug``
|
||
# so they stay out of the everyday surface.
|
||
#
|
||
# ``db-upgrade`` runs manual schema operations on an Omnigent tracking
|
||
# database. Mirrors ``mlflow db upgrade`` (``mlflow/db.py``) so the
|
||
# workflow is familiar to anyone who's bumped an MLflow database before.
|
||
# The server initializes a fresh database on first boot and attempts to
|
||
# auto-upgrade an existing database that is behind head; this command
|
||
# remains available for explicit/manual upgrades, or for retrying an
|
||
# automatic migration that failed.
|
||
#
|
||
# ``migrate-accounts-to-oidc`` remaps user identities when switching the
|
||
# built-in accounts provider to OIDC.
|
||
|
||
|
||
@cli.group("debug")
|
||
def debug() -> None:
|
||
"""Internal maintenance commands (advanced — not needed for normal use).
|
||
|
||
Houses operator-only database and accounts maintenance: tracking-DB
|
||
schema upgrades (``db-upgrade``) and the accounts→OIDC identity remap
|
||
(``migrate-accounts-to-oidc``).
|
||
"""
|
||
|
||
|
||
@debug.command("db-upgrade")
|
||
@click.argument("url")
|
||
def debug_db_upgrade(url: str) -> None:
|
||
"""
|
||
Upgrade the schema of an Omnigent tracking database to the
|
||
latest supported version.
|
||
|
||
URL is a SQLAlchemy database URL, e.g.
|
||
``sqlite:////absolute/path/to/chat.db`` or
|
||
``postgresql://user:pass@host/dbname``.
|
||
|
||
\b
|
||
IMPORTANT: schema migrations can be slow and are not guaranteed
|
||
to be transactional — always take a backup of your database
|
||
before running migrations.
|
||
"""
|
||
from sqlalchemy import create_engine
|
||
|
||
from omnigent.db.utils import _run_migrations
|
||
|
||
click.echo(f"Upgrading {url} ...")
|
||
engine = create_engine(url)
|
||
try:
|
||
_run_migrations(engine, url)
|
||
finally:
|
||
engine.dispose()
|
||
click.echo("Upgrade complete.")
|
||
|
||
|
||
@debug.command("migrate-accounts-to-oidc")
|
||
@click.argument("url")
|
||
@click.option(
|
||
"--map",
|
||
"maps",
|
||
multiple=True,
|
||
metavar="OLD=NEW",
|
||
help="Explicit identity remap, e.g. --map alice=alice@example.com "
|
||
"(repeatable; overrides --domain for the same OLD).",
|
||
)
|
||
@click.option(
|
||
"--domain",
|
||
default=None,
|
||
metavar="DOMAIN",
|
||
help="Append @DOMAIN to every bare (no-@) username, e.g. "
|
||
"--domain example.com maps alice -> alice@example.com.",
|
||
)
|
||
@click.option(
|
||
"--commit",
|
||
is_flag=True,
|
||
default=False,
|
||
help="Apply the changes. Without this flag the command is a "
|
||
"dry run that reports what would change and mutates nothing.",
|
||
)
|
||
@click.option(
|
||
"--force",
|
||
is_flag=True,
|
||
default=False,
|
||
help="Allow merging onto a NEW id that already exists as a "
|
||
"distinct user (merges admin rights). Off by default to avoid "
|
||
"accidental privilege merges.",
|
||
)
|
||
def debug_migrate_accounts_to_oidc(
|
||
url: str,
|
||
maps: tuple[str, ...],
|
||
domain: str | None,
|
||
commit: bool,
|
||
force: bool,
|
||
) -> None:
|
||
"""Remap user identities when switching the accounts provider to OIDC.
|
||
|
||
The accounts provider keys users by username (``alice``); OIDC keys
|
||
them by IdP email (``alice@example.com``). This rewrites every
|
||
user-id-bearing row (permission grants, comments, policies, tokens,
|
||
host ownership) so the team keeps its admin and data across the
|
||
switch. Provider-agnostic: it touches only the database, so run it
|
||
against your live DB *before* flipping ``OMNIGENT_AUTH_PROVIDER``.
|
||
|
||
URL is a SQLAlchemy database URL, e.g.
|
||
``sqlite:////absolute/path/to/chat.db`` or
|
||
``postgresql://user:pass@host/dbname``.
|
||
|
||
\b
|
||
Examples:
|
||
# Dry run: append the org domain to every username
|
||
omnigent debug migrate-accounts-to-oidc sqlite:///chat.db --domain example.com
|
||
# Apply it
|
||
omnigent debug migrate-accounts-to-oidc sqlite:///chat.db --domain example.com --commit
|
||
# Explicit per-user mapping (add --commit to apply)
|
||
omnigent debug migrate-accounts-to-oidc sqlite:///chat.db --map alice=alice@corp.com
|
||
|
||
\b
|
||
IMPORTANT: always back up your database before running with
|
||
--commit. The remap runs in one transaction but rewrites primary
|
||
keys across several tables.
|
||
"""
|
||
from sqlalchemy import create_engine
|
||
|
||
from omnigent.server.identity_migration import build_domain_mapping, remap_identities
|
||
|
||
engine = create_engine(url)
|
||
try:
|
||
mapping: dict[str, str] = {}
|
||
if domain:
|
||
mapping.update(build_domain_mapping(engine, domain))
|
||
# Explicit --map pairs win over the domain-derived mapping.
|
||
for pair in maps:
|
||
if "=" not in pair:
|
||
raise click.BadParameter(f"--map expects OLD=NEW, got {pair!r}")
|
||
old, new = (part.strip() for part in pair.split("=", 1))
|
||
if not old or not new:
|
||
raise click.BadParameter(f"--map expects non-empty OLD=NEW, got {pair!r}")
|
||
mapping[old] = new
|
||
|
||
if not mapping:
|
||
raise click.UsageError("nothing to migrate: pass --domain DOMAIN and/or --map OLD=NEW")
|
||
|
||
report = remap_identities(engine, mapping, dry_run=not commit, force=force)
|
||
finally:
|
||
engine.dispose()
|
||
|
||
mode = "COMMITTED" if report.committed else "DRY RUN (no changes written)"
|
||
click.echo(f"\nIdentity remap — {mode}")
|
||
click.echo(f" database: {url}")
|
||
click.echo(f" mappings ({len(report.mapping)}):")
|
||
for old, new in report.mapping.items():
|
||
click.echo(f" {old} -> {new}")
|
||
|
||
# The NEW ids must equal what the IdP returns at login, or the user
|
||
# signs in as a brand-new principal (not admin, no prior sessions).
|
||
# This is the #1 footgun with --domain when the IdP email isn't
|
||
# <username>@<domain> (e.g. GitHub returning a @gmail.com address).
|
||
click.echo(
|
||
"\n ⚠ Each NEW id must match the email your IdP returns for that user.\n"
|
||
" If it doesn't, that user logs in as a new principal — re-add them to\n"
|
||
" the admin list, or re-run with --map OLD=<exact-idp-email>."
|
||
)
|
||
bare = sorted({new for new in report.mapping.values() if "@" not in new})
|
||
if bare:
|
||
click.echo(
|
||
" These targets have no '@' and are unlikely to be IdP emails: " + ", ".join(bare)
|
||
)
|
||
|
||
if report.per_table:
|
||
click.echo(" rows changed:")
|
||
for table, count in sorted(report.per_table.items()):
|
||
click.echo(f" {table}: {count}")
|
||
else:
|
||
click.echo(" rows changed: none")
|
||
|
||
if report.skipped_missing:
|
||
click.echo(f" skipped (no user row): {', '.join(report.skipped_missing)}")
|
||
if report.refused:
|
||
click.echo(
|
||
" REFUSED (NEW id already exists — re-run with --force to merge): "
|
||
+ ", ".join(report.refused)
|
||
)
|
||
|
||
if not report.committed:
|
||
click.echo("\nThis was a dry run. Re-run with --commit to apply.\n")
|
||
else:
|
||
click.echo("\nDone. Flip OMNIGENT_AUTH_PROVIDER=oidc and restart.\n")
|
||
|
||
|
||
@debug.command("logs")
|
||
@click.option(
|
||
"--type",
|
||
"log_type",
|
||
type=click.Choice(["runner", "host-runner", "server", "cli"], case_sensitive=False),
|
||
default="runner",
|
||
show_default=True,
|
||
help="Log category: runner (local CLI runner via omnigent run), "
|
||
"host-runner (runner spawned by a host daemon), "
|
||
"server (local server), or cli (CLI diagnostics).",
|
||
)
|
||
@click.option(
|
||
"--session",
|
||
"session_id",
|
||
default=None,
|
||
metavar="SESSION_ID",
|
||
help="Filter host-runner logs by session id, e.g. conv_abc123. "
|
||
"Only applies to --type host-runner. Shows all log files for the "
|
||
"session, oldest first.",
|
||
)
|
||
@click.option(
|
||
"--list",
|
||
"list_only",
|
||
is_flag=True,
|
||
default=False,
|
||
help="List available log files with size and timestamp instead of showing content.",
|
||
)
|
||
@click.option(
|
||
"--lines",
|
||
"-n",
|
||
default=50,
|
||
show_default=True,
|
||
metavar="N",
|
||
type=click.IntRange(min=0),
|
||
help="Lines to show from the end of the log (0 = entire file). "
|
||
"With --session, applied per file.",
|
||
)
|
||
@click.option(
|
||
"--follow",
|
||
"-f",
|
||
is_flag=True,
|
||
default=False,
|
||
help="Follow the latest log file in real-time (like tail -f). "
|
||
"With --session, follows the most recent file for the session. "
|
||
"Not supported on Windows.",
|
||
)
|
||
def debug_logs(
|
||
log_type: str, session_id: str | None, list_only: bool, lines: int, follow: bool
|
||
) -> None:
|
||
"""Show runner, server, or CLI diagnostic logs.
|
||
|
||
Prints the tail of the most recent log file for the chosen category.
|
||
Use ``--list`` to see all available files, or ``--follow`` to stream
|
||
new output as it is written.
|
||
|
||
Pass ``--session SESSION_ID`` (``--type host-runner`` only) to scope
|
||
output to all log files produced for a specific session across relaunches.
|
||
|
||
\b
|
||
Log locations (relative to ~/.omnigent or $OMNIGENT_DATA_DIR):
|
||
runner logs/runner/runner-*.log
|
||
host-runner logs/host-runner/runner-*.log
|
||
server logs/server/*server*.log
|
||
cli logs/cli-*.log
|
||
|
||
\b
|
||
Examples:
|
||
# Tail the most recent local runner log (default)
|
||
omnigent debug logs
|
||
# List all local runner log files with sizes
|
||
omnigent debug logs --list
|
||
# Show host-runner logs for a specific session (across relaunches)
|
||
omnigent debug logs --type host-runner --session conv_abc123
|
||
# List host-runner log files for a session
|
||
omnigent debug logs --type host-runner --session conv_abc123 --list
|
||
# Follow the latest server log in real-time
|
||
omnigent debug logs --type server --follow
|
||
# Show the full latest CLI diagnostics log
|
||
omnigent debug logs --type cli -n 0
|
||
"""
|
||
import re
|
||
import subprocess
|
||
|
||
from omnigent.host.local_server import _local_data_dir
|
||
|
||
if session_id is not None and log_type != "host-runner":
|
||
raise click.UsageError("--session is only supported with --type host-runner")
|
||
|
||
if follow and IS_WINDOWS:
|
||
raise click.UsageError("--follow is not supported on Windows")
|
||
|
||
data_dir = _local_data_dir()
|
||
|
||
_log_configs: dict[str, tuple[Path, str]] = {
|
||
"runner": (data_dir / "logs" / "runner", "runner-*.log"),
|
||
"host-runner": (data_dir / "logs" / "host-runner", "runner-*.log"),
|
||
# Covers both server-*.log (omnigent run) and local-server-*.log (daemon).
|
||
"server": (data_dir / "logs" / "server", "*server*.log"),
|
||
"cli": (data_dir / "logs", "cli-*.log"),
|
||
}
|
||
|
||
log_dir, pattern = _log_configs[log_type]
|
||
|
||
if not log_dir.exists():
|
||
raise click.ClickException(f"No {log_type} logs found — {log_dir} does not exist.")
|
||
|
||
if session_id is not None:
|
||
# Sanitize the same way connect.py does so the glob matches.
|
||
slug = re.sub(r"[^\w-]", "", session_id)[:32]
|
||
pattern = f"runner-{slug}-*.log"
|
||
|
||
# Exclude symlinks (e.g. latest-cli.log), sort newest first.
|
||
log_files = sorted(
|
||
(f for f in log_dir.glob(pattern) if not f.is_symlink()),
|
||
key=lambda p: p.stat().st_mtime,
|
||
reverse=True,
|
||
)
|
||
|
||
if not log_files:
|
||
if session_id is not None:
|
||
raise click.ClickException(
|
||
f"No host-runner logs found for session {session_id!r}. "
|
||
"Session ids appear in filenames only for runners launched "
|
||
"after this feature was added."
|
||
)
|
||
raise click.ClickException(f"No {log_type} log files found in {log_dir}.")
|
||
|
||
if list_only:
|
||
header = (
|
||
f"host-runner logs for session {session_id!r} in {log_dir}:"
|
||
if session_id
|
||
else f"{log_type} logs in {log_dir}:"
|
||
)
|
||
click.echo(header)
|
||
for f in log_files:
|
||
stat = f.stat()
|
||
size_kb = stat.st_size / 1024
|
||
mtime = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(stat.st_mtime))
|
||
click.echo(f" {mtime} {size_kb:6.1f} KB {f.name}")
|
||
return
|
||
|
||
if follow:
|
||
# Follow the most recent file only (tail -f can only track one file).
|
||
latest = log_files[0]
|
||
click.echo(f"# {latest}", err=True)
|
||
subprocess.run(["tail", "-f", str(latest)])
|
||
return
|
||
|
||
if session_id is not None:
|
||
# Show all files for the session, oldest first, with separators.
|
||
for f in reversed(log_files):
|
||
click.echo(f"# {f}", err=True)
|
||
content = f.read_text(errors="replace")
|
||
if lines > 0:
|
||
content = "\n".join(content.splitlines()[-lines:])
|
||
click.echo(content)
|
||
click.echo()
|
||
else:
|
||
latest = log_files[0]
|
||
click.echo(f"# {latest}", err=True)
|
||
content = latest.read_text(errors="replace")
|
||
if lines > 0:
|
||
content = "\n".join(content.splitlines()[-lines:])
|
||
click.echo(content)
|
||
|
||
|
||
def _workspace_mount_probe_matches(candidate: str, probe: httpx.Response) -> bool:
|
||
"""Whether a ``/api/2.0/omnigent`` mount probe answered like omnigent.
|
||
|
||
:param candidate: The probed mount base URL, e.g.
|
||
``"https://example.databricks.com/api/2.0/omnigent"``.
|
||
:param probe: The ``GET <candidate>/v1/me`` response.
|
||
:returns: ``True`` when the mount answered 200 (omnigent itself) or
|
||
with a Databricks-fronted shape (302 to ``/oidc/`` or 401 with
|
||
the ``DatabricksRealm`` challenge).
|
||
"""
|
||
return probe.status_code == 200 or (
|
||
_databricks_workspace_login_target(candidate, probe) is not None
|
||
)
|
||
|
||
|
||
def _cached_workspace_bearer(workspace_host: str) -> str | None:
|
||
"""Best-effort bearer for *workspace_host* from the OAuth cache.
|
||
|
||
Unlike :func:`_databricks_workspace_token`, a missing ``databricks``
|
||
extra is not an error here — probe callers simply fall back to
|
||
unauthenticated behavior.
|
||
|
||
:param workspace_host: The workspace host, e.g.
|
||
``"https://example.databricks.com"``.
|
||
:returns: A bearer token, or ``None`` when the ``databricks`` extra
|
||
is not installed or no cached grant resolves for the host.
|
||
"""
|
||
from omnigent.onboarding.databricks_config import databricks_sdk_installed
|
||
|
||
if not databricks_sdk_installed():
|
||
return None
|
||
return _databricks_workspace_token(workspace_host)
|
||
|
||
|
||
_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
||
|
||
|
||
def _with_default_scheme(server_url: str) -> str:
|
||
"""Prepend a scheme to a schemeless server URL, defaulting to https.
|
||
|
||
The internal user guide hands out workspace URLs without a scheme
|
||
(e.g. ``example.cloud.databricks.com/omnigent``), so a missing
|
||
scheme defaults to ``https`` to let that URL be pasted verbatim.
|
||
Loopback hosts (``localhost``, ``127.0.0.1``, ``::1``) default to
|
||
``http`` instead — local dev servers are plain http (the examples
|
||
use ``http://localhost:6767``). A URL that already carries a scheme
|
||
is returned unchanged.
|
||
|
||
:param server_url: The user-supplied server URL, possibly
|
||
schemeless, e.g. ``"example.cloud.databricks.com/omnigent"``.
|
||
:returns: The URL with a scheme, e.g.
|
||
``"https://example.cloud.databricks.com/omnigent"``.
|
||
"""
|
||
from urllib.parse import urlsplit
|
||
|
||
server_url = server_url.strip()
|
||
if "://" in server_url:
|
||
return server_url
|
||
host = urlsplit(f"https://{server_url}").hostname or ""
|
||
scheme = "http" if host in _LOOPBACK_HOSTS else "https"
|
||
return f"{scheme}://{server_url}"
|
||
|
||
|
||
def _workspace_api_server_url(server: str) -> str:
|
||
"""Expand a bare Databricks workspace URL to its omnigent API base.
|
||
|
||
``https://<workspace>`` hosts serve the workspace web app at the
|
||
root; workspace-hosted omnigent lives at ``/api/2.0/omnigent``.
|
||
Users naturally paste the bare host, so when a path-less server URL
|
||
answers like a Databricks workspace web app (a non-omnigent reply
|
||
carrying the ``server: databricks`` header) AND the
|
||
``/api/2.0/omnigent`` mount answers like the API proxy, the
|
||
expanded URL is adopted. Detection is behavioral — no hostname
|
||
patterns — and URLs that already carry a path are returned
|
||
untouched without any probe, the one exception being the
|
||
guide-issued web-UI URL (``https://<ws>/omnigent``): its bare root
|
||
is probed so the pasted web URL logs in just like the bare host
|
||
(a root that is not a workspace leaves the URL untouched).
|
||
|
||
Some workspace edges (Azure) answer the anonymous mount probe with
|
||
a plain 404 — not the AWS proxy's 401-with-``DatabricksRealm``
|
||
challenge — so a mount that works for authenticated callers is
|
||
invisible to the anonymous probe. When the host-keyed Databricks
|
||
OAuth cache holds a grant for the workspace (the user ran
|
||
``databricks auth login``), the mount probe is retried with that
|
||
bearer before giving up.
|
||
|
||
:param server: The user-supplied server URL, e.g.
|
||
``"https://example.databricks.com"``.
|
||
:returns: The normalized base URL without a trailing slash, e.g.
|
||
``"https://example.databricks.com/api/2.0/omnigent"`` — or the
|
||
input (normalized) when expansion does not apply.
|
||
"""
|
||
from urllib.parse import urlsplit, urlunsplit
|
||
|
||
import httpx as _httpx
|
||
|
||
from omnigent.conversation_browser import (
|
||
WORKSPACE_API_PATH,
|
||
WORKSPACE_UI_PATH,
|
||
display_server_url,
|
||
)
|
||
|
||
server = server.rstrip("/")
|
||
parsed = urlsplit(server)
|
||
# Strip any ?o= selector / query / fragment before probing: callers append
|
||
# a path (``f"{base}/v1/..."``), so a query-bearing base would push that
|
||
# path into the query (``…/?o=123/v1/me``) and break the probe + expansion.
|
||
# The selector is carried separately (recorded at login, replayed as the
|
||
# X-Databricks-Org-Id header), never on the base URL.
|
||
if parsed.query or parsed.fragment:
|
||
server = urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", "")).rstrip("/")
|
||
parsed = urlsplit(server)
|
||
# The internal user guide hands out the workspace web-UI URL
|
||
# (``https://<ws>/omnigent``) for browser access; accept it for login
|
||
# too by expanding its bare root to the API mount. A root that does
|
||
# not answer as a Databricks workspace leaves the pasted URL
|
||
# untouched, so a non-workspace server served under ``/omnigent``
|
||
# still works.
|
||
if parsed.scheme == "https" and parsed.path == WORKSPACE_UI_PATH:
|
||
root = urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
|
||
expanded = _workspace_api_server_url(root)
|
||
return expanded if expanded != root else server
|
||
if parsed.path not in ("", "/") or parsed.scheme != "https":
|
||
return server
|
||
try:
|
||
probe = _httpx.get(f"{server}/v1/me", timeout=10.0)
|
||
except _httpx.HTTPError:
|
||
return server
|
||
# Already something we understand at the root: an omnigent server
|
||
# (200 / 401-with-login_url JSON) or a Databricks Apps edge /
|
||
# API proxy (the login-target detector recognizes both).
|
||
if probe.status_code == 200:
|
||
return server
|
||
if _databricks_workspace_login_target(server, probe) is not None:
|
||
return server
|
||
server_header = probe.headers.get("server")
|
||
if server_header is None or server_header.lower() != "databricks":
|
||
return server
|
||
candidate = urlunsplit((parsed.scheme, parsed.netloc, WORKSPACE_API_PATH, "", ""))
|
||
try:
|
||
api_probe = _httpx.get(f"{candidate}/v1/me", timeout=10.0)
|
||
except _httpx.HTTPError:
|
||
return server
|
||
if _workspace_mount_probe_matches(candidate, api_probe):
|
||
click.echo(
|
||
f"Using {display_server_url(candidate)} (Databricks workspace-hosted omnigent)."
|
||
)
|
||
return candidate
|
||
# The anonymous probe came back inconclusive (404 on Azure even
|
||
# when the mount exists). Retry it with a cached workspace bearer;
|
||
# either way, say what was decided — this branch is only reached
|
||
# for genuine workspace web hosts, where a silent decline strands
|
||
# the user on a bare URL that can only 404.
|
||
token = _cached_workspace_bearer(server)
|
||
if token is not None:
|
||
try:
|
||
authed_probe = _httpx.get(
|
||
f"{candidate}/v1/me",
|
||
headers={"Authorization": f"Bearer {token}"},
|
||
timeout=10.0,
|
||
)
|
||
except _httpx.HTTPError:
|
||
authed_probe = None
|
||
if authed_probe is not None and _workspace_mount_probe_matches(candidate, authed_probe):
|
||
click.echo(
|
||
f"Using {display_server_url(candidate)} (Databricks workspace-hosted omnigent)."
|
||
)
|
||
return candidate
|
||
click.echo(
|
||
f"Note: {server} answers like a Databricks workspace, but "
|
||
f"{candidate} did not answer as an omnigent server even with "
|
||
f"the cached workspace credentials. Connecting to {server} as "
|
||
"given; if omnigent is hosted on this workspace, refresh the "
|
||
f"login with `databricks auth login --host {server}` or pass "
|
||
"the full mount URL."
|
||
)
|
||
return server
|
||
click.echo(
|
||
f"Note: {server} answers like a Databricks workspace, but "
|
||
f"{candidate} did not answer the anonymous probe "
|
||
f"(HTTP {api_probe.status_code}). Some edges hide the mount from "
|
||
"unauthenticated requests — if omnigent is hosted on this "
|
||
f"workspace, run `databricks auth login --host {server}` and "
|
||
"retry, or pass the full mount URL."
|
||
)
|
||
return server
|
||
|
||
|
||
def _resolve_server_url(server: str) -> str:
|
||
"""
|
||
Normalize a user-supplied ``--server`` value to the Omnigent API base.
|
||
|
||
Every ``--server`` entry point (and ``login``) needs the same
|
||
normalization, so they all route through here: strip a trailing slash,
|
||
default a schemeless URL to ``https`` (``http`` for loopback hosts),
|
||
then expand a bare Databricks workspace URL — or the ``/omnigent``
|
||
web-UI URL the internal user guide hands out — to the
|
||
``/api/2.0/omnigent`` mount.
|
||
|
||
:param server: A non-empty ``--server`` value, e.g.
|
||
``"example.cloud.databricks.com/omnigent"``.
|
||
:returns: The normalized API base URL without a trailing slash, e.g.
|
||
``"https://example.cloud.databricks.com/api/2.0/omnigent"``.
|
||
"""
|
||
return _workspace_api_server_url(_with_default_scheme(server.rstrip("/")))
|
||
|
||
|
||
def _databricks_workspace_login_target(server: str, probe: httpx.Response) -> str | None:
|
||
"""Return the workspace host when *server* sits behind Databricks auth.
|
||
|
||
Recognizes the two Databricks-fronted deployment shapes from the
|
||
unauthenticated probe alone — no hostname pattern matching, so
|
||
custom domains work too:
|
||
|
||
- **Databricks Apps**: the Apps edge answers with a 302 to the
|
||
fronting workspace's OIDC authorize endpoint
|
||
(``https://<workspace>/oidc/oauth2/v2.0/authorize?...``); the
|
||
redirect names the workspace to authenticate against.
|
||
- **Workspace-hosted omnigent** (e.g.
|
||
``https://<workspace>/api/2.0/omnigent``): the workspace API
|
||
proxy answers 401 with ``WWW-Authenticate: Bearer
|
||
realm="DatabricksRealm"``; the workspace is the URL's own host.
|
||
|
||
:param server: The server URL the user is logging in to, e.g.
|
||
``"https://myapp-123.aws.databricksapps.com"``.
|
||
:param probe: The unauthenticated ``GET /v1/me`` probe response.
|
||
:returns: The workspace host, e.g.
|
||
``"https://example.databricks.com"``, or ``None`` when the
|
||
response matches neither Databricks shape.
|
||
"""
|
||
from urllib.parse import urlparse
|
||
|
||
if probe.status_code in (302, 303, 307):
|
||
raw_location = probe.headers.get("location")
|
||
if raw_location is None:
|
||
return None
|
||
location = urlparse(raw_location)
|
||
if location.scheme != "https" or not location.netloc:
|
||
return None
|
||
if not location.path.startswith("/oidc/"):
|
||
return None
|
||
return f"https://{location.netloc}"
|
||
|
||
if probe.status_code == 401:
|
||
www_authenticate = probe.headers.get("www-authenticate")
|
||
if www_authenticate and "databricksrealm" in www_authenticate.lower():
|
||
parsed = urlparse(server)
|
||
if parsed.scheme == "https" and parsed.netloc:
|
||
return f"https://{parsed.netloc}"
|
||
|
||
return None
|
||
|
||
|
||
def _org_id_from_url(url: str) -> str | None:
|
||
"""Extract the ``?o=<workspace-id>`` workspace selector from *url*.
|
||
|
||
A Databricks host can front many workspaces under one hostname, where
|
||
the bare host resolves to the account and ``?o=<workspace-id>`` picks
|
||
the workspace. The selector is threaded into both the login (to bind
|
||
the grant to the workspace) and every API request (to route to it).
|
||
|
||
:param url: A user-supplied server URL, possibly carrying ``?o=``,
|
||
e.g. ``"https://acme.databricks.com/?o=123"``.
|
||
:returns: The workspace id, e.g. ``"123"``, or ``None`` when absent.
|
||
"""
|
||
from urllib.parse import parse_qs, urlsplit
|
||
|
||
values = parse_qs(urlsplit(url).query).get("o")
|
||
return values[0] if values and values[0] else None
|
||
|
||
|
||
def _host_with_org(workspace_host: str, org_id: str | None) -> str:
|
||
"""Append the ``?o=<org>`` workspace selector to *workspace_host*.
|
||
|
||
``databricks auth login --host https://<ws>/?o=<org>`` makes the CLI
|
||
record ``workspace_id`` in the profile and bind the grant to that
|
||
workspace; without it the grant is account-scoped and the workspace
|
||
rejects it (HTTP 403). Returns *workspace_host* unchanged when no org
|
||
id is known, so single-workspace hosts are untouched.
|
||
|
||
:param workspace_host: The workspace host, e.g.
|
||
``"https://example.databricks.com"``.
|
||
:param org_id: The workspace id from :func:`_org_id_from_url`, or
|
||
``None``.
|
||
:returns: ``"https://<ws>/?o=<org>"`` when *org_id* is set, else
|
||
*workspace_host*.
|
||
"""
|
||
if not org_id:
|
||
return workspace_host
|
||
# Encode (not interpolate) so a value with ``&``/``=`` can't inject extra
|
||
# query params onto the ``--host`` URL; keep the ``/?o=`` slash the CLI wants.
|
||
from urllib.parse import urlencode, urlsplit, urlunsplit
|
||
|
||
parsed = urlsplit(workspace_host.rstrip("/"))
|
||
return urlunsplit(
|
||
(parsed.scheme, parsed.netloc, parsed.path or "/", urlencode({"o": org_id}), "")
|
||
)
|
||
|
||
|
||
def _databricks_login(server: str, workspace_host: str, org_id: str | None = None) -> None:
|
||
"""Log in to a Databricks-fronted Omnigent server.
|
||
|
||
Covers both Databricks Apps deployments and workspace-hosted
|
||
omnigent (``https://<workspace>/api/2.0/omnigent``). Reuses an
|
||
existing host-keyed Databricks CLI OAuth grant when one resolves;
|
||
otherwise runs ``databricks auth login --host <workspace>``
|
||
(browser flow). The minted token is verified against the server
|
||
before anything is stored; a *cached* grant that fails
|
||
verification (e.g. a stale token-cache entry minted for a
|
||
different workspace) triggers one fresh browser login and a
|
||
re-verify before failing loud. On success, a pointer record is
|
||
stored in ``~/.omnigent/auth_tokens.json`` — no profile name is
|
||
created or consulted anywhere.
|
||
|
||
:param server: The server URL, e.g.
|
||
``"https://myapp-123.aws.databricksapps.com"``.
|
||
:param workspace_host: The Databricks workspace to authenticate
|
||
against, e.g. ``"https://example.databricks.com"``.
|
||
:param org_id: The ``?o=`` workspace selector from the login URL
|
||
(see :func:`_org_id_from_url`). When set, the login binds the
|
||
grant to this workspace and the verify request routes to it —
|
||
needed where the bare host is the account, not a workspace.
|
||
:raises click.ClickException: When the ``databricks`` extra or CLI
|
||
binary is missing, the workspace login fails, or the server
|
||
rejects the workspace token.
|
||
"""
|
||
from omnigent.onboarding.databricks_config import (
|
||
DATABRICKS_EXTRA_INSTALL_HINT,
|
||
databricks_sdk_installed,
|
||
)
|
||
|
||
click.echo(f"{server} authenticates via the Databricks workspace {workspace_host}.")
|
||
|
||
if not databricks_sdk_installed():
|
||
raise click.ClickException(
|
||
"Logging in to a Databricks-fronted server (a Databricks App or "
|
||
"workspace-hosted omnigent) requires the `databricks` extra "
|
||
f"(databricks-sdk is not installed). Reinstall with:\n "
|
||
f"{DATABRICKS_EXTRA_INSTALL_HINT}"
|
||
)
|
||
|
||
token = _databricks_workspace_token(workspace_host)
|
||
fresh_login_done = False
|
||
if token is None:
|
||
token = _login_and_mint_workspace_token(workspace_host, org_id)
|
||
fresh_login_done = True
|
||
|
||
# Verify the workspace token actually gets through the edge to THIS
|
||
# server (the user may lack access to it), and learn our identity
|
||
# for the success message.
|
||
verify = _verify_databricks_server_token(server, token, org_id)
|
||
if verify.status_code != 200 and not fresh_login_done:
|
||
# A cached grant can be stale or minted for a different
|
||
# workspace (the CLI token cache is host-keyed but not
|
||
# validated against the issuer). One fresh browser login
|
||
# replaces the bad cache entry; then re-verify.
|
||
click.echo(
|
||
f"The cached Databricks credentials were rejected by {server} "
|
||
f"(HTTP {verify.status_code}) — refreshing the workspace login."
|
||
)
|
||
token = _login_and_mint_workspace_token(workspace_host, org_id)
|
||
verify = _verify_databricks_server_token(server, token, org_id)
|
||
if verify.status_code != 200:
|
||
raise click.ClickException(
|
||
f"{workspace_host} accepted the login, but {server} rejected the token "
|
||
f"(HTTP {verify.status_code}). Check that your user has access to this app."
|
||
)
|
||
user_id: str | None = None
|
||
with contextlib.suppress(ValueError):
|
||
raw_user = verify.json().get("user_id")
|
||
user_id = raw_user if isinstance(raw_user, str) else None
|
||
|
||
from omnigent.cli_auth import store_databricks_auth
|
||
|
||
store_databricks_auth(
|
||
server,
|
||
workspace_host,
|
||
user_id=user_id,
|
||
# Recorded so later commands replay it as ``?o=`` to route requests
|
||
# and browser links append it. The login URL's selector wins; fall
|
||
# back to the org id the workspace stamps on responses.
|
||
org_id=org_id or verify.headers.get("x-databricks-org-id"),
|
||
)
|
||
who = f" as {user_id}" if user_id else ""
|
||
click.echo(
|
||
f"Logged in{who}. Commands targeting {server} now mint workspace tokens automatically."
|
||
)
|
||
|
||
|
||
def _login_and_mint_workspace_token(workspace_host: str, org_id: str | None = None) -> str:
|
||
"""Run the browser login for a workspace and mint a bearer from it.
|
||
|
||
:param workspace_host: The workspace host, e.g.
|
||
``"https://example.databricks.com"``.
|
||
:param org_id: The ``?o=`` workspace selector (see
|
||
:func:`_org_id_from_url`); passed to the browser login so the
|
||
minted grant is bound to the workspace.
|
||
:returns: A fresh bearer token for the workspace.
|
||
:raises click.ClickException: When the Databricks CLI binary is
|
||
missing, the login exits non-zero, or no token resolves after
|
||
a successful login.
|
||
"""
|
||
_run_databricks_browser_login(workspace_host, org_id)
|
||
token = _databricks_workspace_token(workspace_host)
|
||
if token is None:
|
||
raise click.ClickException(
|
||
f"Workspace login completed but no token resolves for {workspace_host}. "
|
||
f"Run `databricks auth token --host {workspace_host}` to debug."
|
||
)
|
||
return token
|
||
|
||
|
||
def _run_databricks_browser_login(workspace_host: str, org_id: str | None = None) -> None:
|
||
"""Run ``databricks auth login --host <workspace>`` (browser flow).
|
||
|
||
:param workspace_host: The workspace host, e.g.
|
||
``"https://example.databricks.com"``.
|
||
:param org_id: The ``?o=`` workspace selector (see
|
||
:func:`_org_id_from_url`). When set, ``?o=<org_id>`` is appended
|
||
to ``--host`` so the CLI records ``workspace_id`` and binds the
|
||
grant to that workspace (else the grant is account-scoped and
|
||
the workspace rejects it).
|
||
:raises click.ClickException: When the Databricks CLI binary is
|
||
missing or the login exits non-zero.
|
||
"""
|
||
databricks_bin = shutil.which("databricks")
|
||
if databricks_bin is None:
|
||
raise click.ClickException(
|
||
"The Databricks CLI is required to log in to a workspace. "
|
||
"Install it first: https://docs.databricks.com/dev-tools/cli/install.html"
|
||
)
|
||
login_host = _host_with_org(workspace_host, org_id)
|
||
click.echo(f"Opening browser to log in to {login_host} ...")
|
||
result = subprocess.run(
|
||
[databricks_bin, "auth", "login", "--host", login_host],
|
||
check=False,
|
||
)
|
||
if result.returncode != 0:
|
||
raise click.ClickException(
|
||
f"`databricks auth login --host {login_host}` failed "
|
||
f"(exit {result.returncode}). If the workspace is unreachable from "
|
||
"this machine (VPN / IP access lists), resolve that and retry."
|
||
)
|
||
|
||
|
||
def _verify_databricks_server_token(
|
||
server: str, token: str, org_id: str | None = None
|
||
) -> httpx.Response:
|
||
"""Probe ``GET /v1/me`` on *server* with a workspace bearer.
|
||
|
||
:param server: The server URL, e.g.
|
||
``"https://myapp-123.aws.databricksapps.com"``.
|
||
:param token: The workspace bearer token to present.
|
||
:param org_id: The ``?o=`` workspace selector (see
|
||
:func:`_org_id_from_url`). When set, the probe carries
|
||
``?o=<org_id>`` so the request routes to the workspace rather
|
||
than defaulting to the account (which answers HTTP 503).
|
||
:returns: The probe response (200 means the token is accepted and
|
||
the body carries ``user_id``).
|
||
:raises click.ClickException: When the server is unreachable.
|
||
"""
|
||
import httpx as _httpx
|
||
|
||
try:
|
||
return _httpx.get(
|
||
f"{server}/v1/me",
|
||
headers={"Authorization": f"Bearer {token}"},
|
||
params={"o": org_id} if org_id else None,
|
||
timeout=10.0,
|
||
)
|
||
except _httpx.HTTPError as exc:
|
||
raise click.ClickException(
|
||
f"Could not reach {server}/v1/me to verify login: {exc}"
|
||
) from exc
|
||
|
||
|
||
def _databricks_workspace_token(workspace_host: str) -> str | None:
|
||
"""Mint a bearer for a workspace from the host-keyed OAuth cache.
|
||
|
||
:param workspace_host: The workspace host, e.g.
|
||
``"https://example.databricks.com"``.
|
||
:returns: A bearer token, or ``None`` when no cached grant
|
||
resolves (the caller should run ``databricks auth login``).
|
||
"""
|
||
from omnigent.inner.databricks_executor import (
|
||
DatabricksAuthError,
|
||
_resolve_databricks_auth,
|
||
)
|
||
|
||
try:
|
||
auth, _host = _resolve_databricks_auth(host=workspace_host)
|
||
return auth.current_token()
|
||
except (DatabricksAuthError, ValueError):
|
||
return None
|
||
|
||
|
||
def _remember_default_server(server: str) -> None:
|
||
"""
|
||
Persist *server* as the user-level default after a successful login.
|
||
|
||
A bare ``omnigent`` (and ``omnigent host``) fall back to the
|
||
configured ``server`` key when no ``--server`` is passed (see
|
||
:func:`run` and :func:`host`). Without this, a user who runs
|
||
``omnigent login <server>`` and then bare ``omnigent`` is still routed
|
||
at whatever default ``setup`` baked in — the confusing "I just logged
|
||
in, yet I'm asked to log in again to a different server" path.
|
||
Recording the just-logged-in server as the default closes that gap.
|
||
|
||
Any existing default is overwritten: targeting more than one server is
|
||
rare, and the server the user most recently logged in to is the best
|
||
available signal of intent.
|
||
|
||
:param server: Normalized server URL the login succeeded against, e.g.
|
||
``"https://example.databricks.com/api/2.0/omnigent"``.
|
||
"""
|
||
_save_global_config({"server": server})
|
||
click.echo(f"Set {server} as your default server.")
|
||
|
||
|
||
@cli.command("login")
|
||
@click.argument("server_url")
|
||
def login(server_url: str) -> None:
|
||
"""Authenticate with a remote Omnigent server.
|
||
|
||
Probes the server's auth mode and runs the matching flow:
|
||
|
||
\b
|
||
- accounts mode: prompts for username + password (no browser
|
||
needed), POSTs ``/auth/login``, stores the session JWT in
|
||
``~/.omnigent/auth_tokens.json`` keyed by server URL.
|
||
- OIDC mode: opens the browser, polls the CLI ticket endpoint,
|
||
stores the session JWT when the browser flow completes.
|
||
- header mode: no login needed (proxy injects identity); we
|
||
print a hint and exit successfully.
|
||
- Databricks-fronted (a Databricks App, or omnigent hosted on
|
||
a workspace API path): detected from the probe response — we
|
||
log in to the workspace via ``databricks auth login --host
|
||
<workspace>`` (browser) and store a pointer record so later
|
||
commands mint fresh workspace tokens automatically. Requires
|
||
the ``databricks`` extra.
|
||
|
||
Subsequent ``omnigent run --server <url>`` commands then
|
||
use the stored token via the runner / host-tunnel auth chain. A
|
||
successful login also records the server as the user-level default
|
||
(the ``server`` key in ``~/.omnigent/config.yaml``), so a bare
|
||
``omnigent`` afterwards targets it instead of whatever default
|
||
``setup`` baked in.
|
||
|
||
\b
|
||
Example:
|
||
omnigent login http://localhost:6767
|
||
omnigent login example.cloud.databricks.com/omnigent # https:// assumed
|
||
omnigent # connects to the server just logged in to
|
||
|
||
:param server_url: The remote server URL, e.g.
|
||
``"http://localhost:6767"``. A missing scheme defaults to
|
||
``https://`` (``http://`` for loopback hosts), and the workspace
|
||
web-UI URL (``<ws>/omnigent``) is accepted alongside the bare
|
||
workspace root.
|
||
"""
|
||
import httpx as _httpx
|
||
|
||
server = _resolve_server_url(server_url)
|
||
# Read the ``?o=`` selector from the raw input: normalization strips the
|
||
# query when expanding to the API mount.
|
||
org_id = _org_id_from_url(server_url)
|
||
|
||
# ── Step 0: Probe the server's auth mode. ──────────────────
|
||
# /v1/me returns a JSON ``login_url`` on 401 — "/login" for
|
||
# accounts, "/auth/login" for OIDC, and no login_url at all
|
||
# for header mode. A 302 to a workspace OAuth page (Databricks
|
||
# Apps) or a 401 with a DatabricksRealm challenge (workspace-
|
||
# hosted omnigent) means Databricks fronts the server. This
|
||
# lets one CLI command handle every posture without a flag.
|
||
try:
|
||
probe = _httpx.get(f"{server}/v1/me", timeout=10.0)
|
||
except _httpx.HTTPError as exc:
|
||
raise click.ClickException(
|
||
f"Could not reach {server}/v1/me: {exc}\nIs the server running?"
|
||
) from exc
|
||
|
||
databricks_workspace = _databricks_workspace_login_target(server, probe)
|
||
if databricks_workspace is not None:
|
||
_databricks_login(server, databricks_workspace, org_id=org_id)
|
||
_remember_default_server(server)
|
||
return
|
||
|
||
detected_login_url: str | None = None
|
||
if probe.status_code == 401:
|
||
import contextlib as _contextlib
|
||
|
||
# 401 with non-JSON body — probably not an Omnigent server.
|
||
# Suppress: we fall through to the OIDC path below which has
|
||
# its own clearer error message.
|
||
with _contextlib.suppress(ValueError):
|
||
detected_login_url = probe.json().get("login_url")
|
||
elif probe.status_code == 200:
|
||
# Header mode (or already authenticated). Tell the user
|
||
# they don't need to log in and exit cleanly.
|
||
click.echo(
|
||
f"{server} is in header-auth mode — no login needed. "
|
||
"The proxy in front of it injects your identity on every "
|
||
"request."
|
||
)
|
||
_remember_default_server(server)
|
||
return
|
||
|
||
if detected_login_url == "/login":
|
||
_accounts_login(server)
|
||
_remember_default_server(server)
|
||
return
|
||
|
||
# Fall through: OIDC mode (or unknown — let the ticket endpoint's
|
||
# error message guide the user).
|
||
import webbrowser
|
||
|
||
from omnigent.cli_auth import store_token
|
||
|
||
# Step 1: Request a CLI login ticket.
|
||
try:
|
||
resp = _httpx.post(f"{server}/auth/cli-login", timeout=10.0)
|
||
resp.raise_for_status()
|
||
except _httpx.HTTPError as exc:
|
||
raise click.ClickException(
|
||
f"Could not reach {server}/auth/cli-login: {exc}\n"
|
||
f"Is the server running with OMNIGENT_AUTH_PROVIDER=oidc?"
|
||
) from exc
|
||
|
||
data = resp.json()
|
||
ticket = data["ticket"]
|
||
login_url = f"{server}{data['login_url']}"
|
||
|
||
# Step 2: Open the browser.
|
||
click.echo(f"Opening browser for login: {login_url}")
|
||
click.echo("Waiting for authentication...")
|
||
webbrowser.open(login_url)
|
||
|
||
# Step 3: Poll until the ticket is fulfilled or expired.
|
||
poll_url = f"{server}/auth/cli-poll?ticket={ticket}"
|
||
import time as _time
|
||
|
||
deadline = _time.time() + _CLI_LOGIN_TIMEOUT_SECONDS
|
||
while _time.time() < deadline:
|
||
_time.sleep(2)
|
||
try:
|
||
poll_resp = _httpx.get(poll_url, timeout=10.0)
|
||
except _httpx.HTTPError:
|
||
continue
|
||
|
||
if poll_resp.status_code == 202:
|
||
# Still pending.
|
||
continue
|
||
if poll_resp.status_code == 200:
|
||
result = poll_resp.json()
|
||
token = result["token"]
|
||
user_id = result["user_id"]
|
||
expires_in = result.get("expires_in", 8 * 3600)
|
||
store_token(
|
||
server_url=server,
|
||
token=token,
|
||
user_id=user_id,
|
||
expires_at=_time.time() + expires_in,
|
||
)
|
||
click.echo(f"Logged in as {user_id}")
|
||
_remember_default_server(server)
|
||
return
|
||
# 410 or other error — ticket expired.
|
||
raise click.ClickException("Login ticket expired or was rejected. Please try again.")
|
||
|
||
raise click.ClickException(
|
||
"Login timed out — the browser flow was not completed "
|
||
f"within {_CLI_LOGIN_TIMEOUT_SECONDS} seconds."
|
||
)
|
||
|
||
|
||
_CLI_LOGIN_TIMEOUT_SECONDS = 300 # 5 minutes
|
||
|
||
|
||
def _accounts_login(server: str) -> None:
|
||
"""Run the accounts-mode login flow: prompt + POST /auth/login.
|
||
|
||
No browser, no polling — accounts auth is username + password,
|
||
we just collect them, send them, and store the returned JWT.
|
||
|
||
Three failure paths surface as ClickExceptions so the click
|
||
error formatter renders them consistently with the rest of
|
||
the CLI:
|
||
|
||
- Network failure on /auth/login → connection error.
|
||
- 401 from /auth/login → "invalid username or password"
|
||
(the server's generic message — we don't reveal whether
|
||
the username was unknown or the password was wrong).
|
||
- 5xx → "server error".
|
||
|
||
On success, the session JWT goes to
|
||
``~/.omnigent/auth_tokens.json`` via the existing
|
||
:func:`omnigent.cli_auth.store_token`. From there both
|
||
``omnigent run`` and ``omnigent host`` pick it up
|
||
automatically when they call ``--server <url>``.
|
||
"""
|
||
import httpx as _httpx
|
||
|
||
from omnigent.cli_auth import store_token
|
||
|
||
click.echo(f"Signing in to {server} (accounts auth).")
|
||
# `admin` is the bootstrap username; prefill to match what
|
||
# the web LoginPage does.
|
||
username = click.prompt("Username", default="admin")
|
||
password = click.prompt("Password", hide_input=True)
|
||
|
||
try:
|
||
resp = _httpx.post(
|
||
f"{server}/auth/login",
|
||
json={"username": username, "password": password},
|
||
timeout=10.0,
|
||
)
|
||
except _httpx.HTTPError as exc:
|
||
raise click.ClickException(f"Could not reach {server}/auth/login: {exc}") from exc
|
||
|
||
if resp.status_code == 401:
|
||
# Generic message — matches what the server returns and
|
||
# what the web form shows. Don't echo the username back
|
||
# in case the terminal is being recorded / shared.
|
||
raise click.ClickException("Invalid username or password.")
|
||
if resp.status_code >= 500:
|
||
raise click.ClickException("Server error during login. Try again in a moment.")
|
||
if not resp.is_success:
|
||
raise click.ClickException(f"Login failed ({resp.status_code}): {resp.text[:200]}")
|
||
|
||
body = resp.json()
|
||
token = body["token"]
|
||
user_id = body["user"]["id"]
|
||
expires_in = body.get("expires_in", 8 * 3600)
|
||
|
||
import time as _time
|
||
|
||
store_token(
|
||
server_url=server,
|
||
token=token,
|
||
user_id=user_id,
|
||
expires_at=_time.time() + expires_in,
|
||
)
|
||
click.echo(f"Logged in as {user_id}.")
|
||
|
||
|
||
# Direction codes used by ``pane-split`` and ``pane-picker``.
|
||
# ``"v"`` = vertical split (new pane stacked below; tmux ``-v``).
|
||
# ``"h"`` = horizontal split (new pane side-by-side; tmux ``-h``).
|
||
# ``"w"`` = new window/tab (tmux ``new-window``).
|
||
_PANE_SPLIT_DIRECTIONS = ("v", "h", "w")
|
||
|
||
|
||
@cli.command("pane-split", hidden=True)
|
||
@click.option("-v", "direction", flag_value="v", help="Vertical split (new pane below)")
|
||
@click.option(
|
||
"-h",
|
||
"direction",
|
||
flag_value="h",
|
||
help="Horizontal split (new pane to the right)",
|
||
)
|
||
@click.option("-w", "direction", flag_value="w", help="New window/tab")
|
||
@click.option(
|
||
"-p",
|
||
"--parent-pane",
|
||
"parent_pane",
|
||
required=True,
|
||
help="Tmux pane id of the parent omnigent pane (e.g. '%0'). "
|
||
"Forwarded by the wrapped key-binding via #{pane_id}.",
|
||
)
|
||
def pane_split(direction: str | None, parent_pane: str) -> None:
|
||
"""
|
||
Split the parent omnigent pane and run the chooser in the new pane.
|
||
|
||
Internal subcommand invoked by the tmux key-binding wrappers
|
||
installed by ``omnigent.repl._tmux_pane``. The wrapper fires
|
||
``run-shell 'omnigent pane-split -<v|h|w> -p #{pane_id}'`` when
|
||
the user presses their split key while focused on an omnigent
|
||
pane; tmux substitutes ``#{pane_id}`` to the focused pane's id
|
||
and we exec the right ``tmux split-window`` / ``new-window``
|
||
invocation pointing at ``omnigent pane-picker``.
|
||
|
||
:param direction: One of ``v`` / ``h`` / ``w``. Required.
|
||
:param parent_pane: The omnigent pane id, e.g. ``%0``. Required.
|
||
"""
|
||
import shlex
|
||
|
||
from omnigent.repl._tmux_pane import _resolve_omnigent_argv
|
||
|
||
if direction not in _PANE_SPLIT_DIRECTIONS:
|
||
raise click.ClickException("pane-split requires exactly one of -v, -h, or -w")
|
||
# The new pane runs ``omnigent pane-picker`` which reads the
|
||
# parent's pane options and exec's into the chosen agent run.
|
||
# We pass the parent pane id explicitly because the new pane's
|
||
# ``$TMUX_PANE`` will be the new pane, not the parent.
|
||
#
|
||
# tmux's ``split-window`` / ``new-window`` spawns the new
|
||
# pane's initial command via ``/bin/sh -c``, and that shell
|
||
# inherits the tmux server's PATH — which typically does NOT
|
||
# include the venv ``bin/`` where ``omnigent`` lives.
|
||
# ``_resolve_omnigent_argv`` returns either an absolute
|
||
# path to the binary (preferred) or ``[python, "-m",
|
||
# "omnigent.cli"]`` as a fallback that always works.
|
||
picker_argv = [
|
||
*_resolve_omnigent_argv(),
|
||
"pane-picker",
|
||
"--parent-pane",
|
||
parent_pane,
|
||
]
|
||
picker_cmd = " ".join(shlex.quote(p) for p in picker_argv)
|
||
# Resolve the parent pane's working directory and pass it via
|
||
# ``-c`` so the new pane inherits the same cwd. Without this,
|
||
# tmux's ``split-window`` / ``new-window`` defaults to the
|
||
# tmux server's cwd (often the user's HOME), which means
|
||
# relative agent paths in the parent's launch argv (e.g.
|
||
# ``examples/databricks_coding_agent.yaml``) don't resolve in
|
||
# the new pane and the spawned REPL exits with "agent path
|
||
# not found" within seconds.
|
||
parent_cwd = subprocess.run(
|
||
["tmux", "display-message", "-p", "-t", parent_pane, "-F", "#{pane_current_path}"],
|
||
capture_output=True,
|
||
text=True,
|
||
check=False,
|
||
).stdout.strip()
|
||
cwd_args = ["-c", parent_cwd] if parent_cwd else []
|
||
if direction == "v":
|
||
argv = ["tmux", "split-window", "-v", "-t", parent_pane, *cwd_args, picker_cmd]
|
||
elif direction == "h":
|
||
argv = ["tmux", "split-window", "-h", "-t", parent_pane, *cwd_args, picker_cmd]
|
||
else: # "w"
|
||
argv = ["tmux", "new-window", *cwd_args, picker_cmd]
|
||
os.execvp("tmux", argv)
|
||
|
||
|
||
@cli.command("pane-picker", hidden=True)
|
||
@click.option(
|
||
"--parent-pane",
|
||
"parent_pane",
|
||
required=True,
|
||
help="Tmux pane id of the parent omnigent pane (e.g. '%0'). "
|
||
"Used to read launch context (agent name, launch argv, server URL) "
|
||
"from custom pane options the parent set via "
|
||
"``omnigent.repl._tmux_pane.register_pane``.",
|
||
)
|
||
def pane_picker(parent_pane: str) -> None:
|
||
"""
|
||
Launch a fresh REPL conversation in the current new pane.
|
||
|
||
Internal subcommand. The new tmux pane (created by
|
||
``omnigent pane-split``) execs this command, which:
|
||
|
||
1. Reads the parent omnigent pane's ``@omnigent-launch-argv``
|
||
and friends.
|
||
2. ``os.execvp``\\s the parent's launch argv to spawn a new
|
||
REPL against the same agent in this pane.
|
||
|
||
v1 has exactly one path: "new conversation with the same
|
||
agent". A chooser dialog (sub-agent listing, "continue
|
||
sub-agent X", etc.) lands in Phase 2 — see
|
||
``designs/REPL_TMUX_PANE_SPLIT.md``. With only one option,
|
||
a chooser is friction; we just exec.
|
||
|
||
:param parent_pane: The parent omnigent pane id, e.g. ``%0``.
|
||
"""
|
||
import json
|
||
|
||
from omnigent.repl._tmux_pane import (
|
||
OPT_LAUNCH_ARGV,
|
||
read_pane_option,
|
||
)
|
||
|
||
launch_argv_json = read_pane_option(parent_pane, OPT_LAUNCH_ARGV)
|
||
if not launch_argv_json:
|
||
click.echo(
|
||
f"error: parent pane {parent_pane} has no omnigent context "
|
||
f"(missing {OPT_LAUNCH_ARGV} option). Cannot launch sibling REPL.",
|
||
err=True,
|
||
)
|
||
sys.exit(1)
|
||
try:
|
||
launch_argv = json.loads(launch_argv_json)
|
||
except json.JSONDecodeError as exc:
|
||
click.echo(
|
||
f"error: parent pane {parent_pane}'s {OPT_LAUNCH_ARGV} option "
|
||
f"is not valid JSON: {exc}",
|
||
err=True,
|
||
)
|
||
sys.exit(1)
|
||
if not isinstance(launch_argv, list) or not launch_argv:
|
||
click.echo(
|
||
f"error: parent pane {parent_pane}'s launch argv is empty or "
|
||
f"not a list — cannot reconstruct a launch command.",
|
||
err=True,
|
||
)
|
||
sys.exit(1)
|
||
|
||
# Strip resume-related flags from the parent's argv so the new
|
||
# pane starts a FRESH conversation instead of trying to resume
|
||
# the parent's. The parent may have been launched with
|
||
# ``--resume`` (bare picker), ``--resume <id>`` (specific
|
||
# conversation pin), or ``--continue`` (latest-conv shortcut);
|
||
# replaying them in the new pane would re-open the parent's
|
||
# conversation, defeating the point of a sibling pane. Legacy
|
||
# ``--session <id>`` is also handled here so pre-consolidation
|
||
# parent argvs still sanitize cleanly.
|
||
fresh_argv = _strip_resume_flags(launch_argv)
|
||
# Same treatment for ``-p`` / ``--prompt`` and ``--system-prompt``:
|
||
# the parent's auto-prompt was for THAT conversation; we don't
|
||
# want the new pane to silently re-send it.
|
||
fresh_argv = _strip_one_shot_flags(fresh_argv)
|
||
os.execvp(fresh_argv[0], fresh_argv)
|
||
|
||
|
||
# Pure boolean resume flags: presence drops one token.
|
||
# ``-c`` is the short form of ``--continue`` (resume most-recent).
|
||
_RESUME_BOOLEAN_FLAGS = frozenset({"--continue", "-c"})
|
||
|
||
# Resume flags with an optional value: ``--resume`` / ``-r`` may
|
||
# appear bare (interactive picker) OR with a conversation id
|
||
# (``--resume conv_abc``). We peek at the next token to decide
|
||
# whether to drop one or two tokens. Legacy ``--session`` / ``-s``
|
||
# remain here so an argv saved by a pre-consolidation client can
|
||
# still be sanitized cleanly — newly-saved argvs won't contain them.
|
||
_RESUME_OPTIONAL_VALUE_FLAGS = frozenset({"--resume", "-r", "--session", "-s"})
|
||
|
||
# One-shot flags whose value is bound to a specific conversation
|
||
# (the parent's first user message) and thus shouldn't be replayed
|
||
# verbatim in a sibling pane. Same valued-flag shape as resume.
|
||
_ONE_SHOT_VALUED_FLAGS = frozenset({"-p", "--prompt", "--system-prompt"})
|
||
|
||
|
||
def _strip_resume_flags(argv: list[str]) -> list[str]:
|
||
"""
|
||
Return *argv* with all resume-related flags removed.
|
||
|
||
Handles three flag shapes:
|
||
|
||
- Boolean-only flags (``--continue`` / ``-c``): drop the single
|
||
token.
|
||
- Optional-value flags (``--resume`` / ``-r``, plus the legacy
|
||
``--session`` / ``-s``): if followed by a non-flag token, drop
|
||
both; otherwise drop just the flag.
|
||
- Long-form ``--key=value`` (``--resume=<id>`` /
|
||
``--session=<id>``): drop the single combined token.
|
||
|
||
:param argv: Parent's launch argv, e.g.
|
||
``["python", "-m", "omnigent.cli", "run", "agent.yaml",
|
||
"--model", "my-model", "--resume"]``.
|
||
:returns: The same argv with resume flags removed. Other flags
|
||
(``--model``, ``--harness``, etc.) survive untouched.
|
||
"""
|
||
out: list[str] = []
|
||
skip_next = False
|
||
for idx, token in enumerate(argv):
|
||
if skip_next:
|
||
skip_next = False
|
||
continue
|
||
if token in _RESUME_BOOLEAN_FLAGS:
|
||
continue
|
||
if token in _RESUME_OPTIONAL_VALUE_FLAGS:
|
||
next_token = argv[idx + 1] if idx + 1 < len(argv) else None
|
||
if next_token is not None and not next_token.startswith("-"):
|
||
skip_next = True
|
||
continue
|
||
# ``--resume=value`` / ``--session=value`` long-form.
|
||
if "=" in token:
|
||
head = token.split("=", 1)[0]
|
||
if head in _RESUME_OPTIONAL_VALUE_FLAGS:
|
||
continue
|
||
out.append(token)
|
||
return out
|
||
|
||
|
||
def _strip_one_shot_flags(argv: list[str]) -> list[str]:
|
||
"""
|
||
Return *argv* with one-shot conversation flags
|
||
(``-p``/``--prompt``/``--system-prompt``) removed.
|
||
|
||
Same flag-shape handling as :func:`_strip_resume_flags`. The
|
||
parent's ``-p "do X"`` was for the parent's first user turn;
|
||
re-applying it in a sibling pane would silently auto-send the
|
||
same prompt, surprising the user.
|
||
"""
|
||
out: list[str] = []
|
||
skip_next = False
|
||
for token in argv:
|
||
if skip_next:
|
||
skip_next = False
|
||
continue
|
||
if token in _ONE_SHOT_VALUED_FLAGS:
|
||
skip_next = True
|
||
continue
|
||
if "=" in token:
|
||
head = token.split("=", 1)[0]
|
||
if head in _ONE_SHOT_VALUED_FLAGS:
|
||
continue
|
||
out.append(token)
|
||
return out
|
||
|
||
|
||
if __name__ == "__main__":
|
||
cli()
|