chore: import upstream snapshot with attribution
This commit is contained in:
Executable
+130
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Backend-only local smoke test.
|
||||
#
|
||||
# Validates the Python backend and local API server from THIS checkout without
|
||||
# building the web UI, configuring provider credentials, creating sessions, or
|
||||
# running agents. Useful for a quick server/API smoke check on your working
|
||||
# copy or current `main`.
|
||||
#
|
||||
# Everything (toolchain, project venv, config, data, database, artifacts, logs,
|
||||
# caches) lives under one disposable runtime directory that is removed on exit,
|
||||
# so the run never touches your real ~/.omnigent, ~/.config / ~/Library, or
|
||||
# package caches.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/backend-smoke.sh # uses port 18080
|
||||
# PORT=18090 scripts/backend-smoke.sh # override the port if 18080 is busy
|
||||
#
|
||||
# Requires: bash, python3 (3.12+), git, curl, and network access to PyPI. No
|
||||
# provider credentials needed. Works on Linux and macOS.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PORT="${PORT:-18080}"
|
||||
repo_root="$(git -C "$(dirname "$0")" rev-parse --show-toplevel)"
|
||||
|
||||
runtime_root="$(mktemp -d "${TMPDIR:-/tmp}/omnigent_backend_XXXXXX")"
|
||||
toolchain_venv="$runtime_root/toolchain_venv"
|
||||
project_venv="$runtime_root/project_venv"
|
||||
runtime_home="$runtime_root/home"
|
||||
runtime_tmp="$runtime_root/tmp"
|
||||
runtime_config="$runtime_root/config"
|
||||
runtime_data="$runtime_root/data"
|
||||
runtime_cache="$runtime_root/cache"
|
||||
runtime_logs="$runtime_root/logs"
|
||||
runtime_artifacts="$runtime_root/artifacts"
|
||||
runtime_db="$runtime_data/omnigent/chat.db"
|
||||
server_pid=""
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$server_pid" ]; then
|
||||
kill -TERM "$server_pid" 2>/dev/null || true
|
||||
wait "$server_pid" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "$runtime_root"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p \
|
||||
"$runtime_home" "$runtime_tmp" \
|
||||
"$runtime_config/xdg" "$runtime_data/xdg" "$runtime_cache/xdg" \
|
||||
"$runtime_cache/pip" "$runtime_cache/uv" \
|
||||
"$runtime_logs" "$runtime_artifacts" \
|
||||
"$(dirname "$runtime_db")" \
|
||||
"$runtime_config/omnigent" "$runtime_data/omnigent"
|
||||
|
||||
# Isolated environment shared by every step. HOME is the primary isolation
|
||||
# lever (it covers ~/.config on Linux and ~/Library on macOS); the explicit
|
||||
# UV_/PIP_/OMNIGENT_ overrides pin the toolchain and app state regardless of
|
||||
# OS. XDG_* are set so an XDG var already exported in the caller's shell can't
|
||||
# redirect state back to the real home. OMNIGENT_SKIP_WEB_UI leaves the server
|
||||
# in API-only mode (no web UI build); UV_PROJECT_ENVIRONMENT points uv at the
|
||||
# throwaway project venv; UV_PYTHON_DOWNLOAD=never keeps uv from fetching
|
||||
# interpreters.
|
||||
env_vars=(
|
||||
"HOME=$runtime_home"
|
||||
"TMPDIR=$runtime_tmp"
|
||||
"XDG_CONFIG_HOME=$runtime_config/xdg"
|
||||
"XDG_DATA_HOME=$runtime_data/xdg"
|
||||
"XDG_CACHE_HOME=$runtime_cache/xdg"
|
||||
"PIP_CACHE_DIR=$runtime_cache/pip"
|
||||
"UV_CACHE_DIR=$runtime_cache/uv"
|
||||
"UV_PROJECT_ENVIRONMENT=$project_venv"
|
||||
"UV_PYTHON_DOWNLOAD=never"
|
||||
"OMNIGENT_CONFIG_HOME=$runtime_config/omnigent"
|
||||
"OMNIGENT_DATA_DIR=$runtime_data/omnigent"
|
||||
"OMNIGENT_DATABASE_URI=sqlite:///$runtime_db"
|
||||
"OMNIGENT_SKIP_WEB_UI=true"
|
||||
)
|
||||
|
||||
echo "Runtime dir: $runtime_root"
|
||||
echo "Checkout: $repo_root"
|
||||
|
||||
echo "Installing uv into a throwaway toolchain venv..."
|
||||
python3 -m venv "$toolchain_venv"
|
||||
python3 -m venv "$project_venv"
|
||||
env "${env_vars[@]}" "$toolchain_venv/bin/python" -m pip install --quiet "uv>=0.11.8"
|
||||
|
||||
echo "Syncing project dependencies (uv sync --frozen)..."
|
||||
( cd "$repo_root" && env "${env_vars[@]}" "$toolchain_venv/bin/uv" sync --frozen )
|
||||
|
||||
echo "Starting backend server on 127.0.0.1:$PORT (API-only)..."
|
||||
nohup env "${env_vars[@]}" \
|
||||
"$project_venv/bin/omnigent" server \
|
||||
--host 127.0.0.1 \
|
||||
--port "$PORT" \
|
||||
--database-uri "sqlite:///$runtime_db" \
|
||||
--artifact-location "$runtime_artifacts" \
|
||||
< /dev/null > "$runtime_logs/omnigent_server.log" 2>&1 &
|
||||
server_pid="$!"
|
||||
|
||||
echo "Waiting for /health..."
|
||||
ready=""
|
||||
for _ in {1..60}; do
|
||||
code="$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 \
|
||||
"http://127.0.0.1:${PORT}/health" || true)"
|
||||
if [ "$code" = "200" ]; then ready=1; break; fi
|
||||
sleep 0.5
|
||||
done
|
||||
if [ -z "$ready" ]; then
|
||||
echo "ERROR: server did not become healthy in time; last log lines:" >&2
|
||||
tail -n 40 "$runtime_logs/omnigent_server.log" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Smoke-testing API endpoints (expect 200):"
|
||||
status=0
|
||||
for path in / /health /docs /v1/agents /v1/sessions; do
|
||||
code="$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 \
|
||||
"http://127.0.0.1:${PORT}${path}")"
|
||||
printf " %-14s %s\n" "$path" "$code"
|
||||
[ "$code" = "200" ] || status=1
|
||||
done
|
||||
|
||||
if [ "$status" -eq 0 ]; then
|
||||
echo "OK: all endpoints returned 200."
|
||||
else
|
||||
echo "FAIL: one or more endpoints did not return 200." >&2
|
||||
fi
|
||||
exit "$status"
|
||||
@@ -0,0 +1,849 @@
|
||||
"""Generate and post-process the omnigent OpenAPI 3.2 document.
|
||||
|
||||
The omnigent server runs on FastAPI 0.135.x, which emits OpenAPI
|
||||
3.1. OpenAPI 3.2 (released September 2025) introduced first-class
|
||||
support for sequential media types — specifically, the
|
||||
``itemSchema`` keyword for describing each item in a streaming
|
||||
response on a ``text/event-stream`` content entry. We need 3.2's
|
||||
``itemSchema`` so the SSE routes describe their per-event shape
|
||||
correctly to consuming SDK / docs tooling.
|
||||
|
||||
This script:
|
||||
|
||||
1. Imports :func:`omnigent.server.app.create_app` and instantiates
|
||||
it against in-memory store stubs (no DB needed).
|
||||
2. Calls ``app.openapi()`` to get the FastAPI-generated 3.1 dict.
|
||||
3. Bumps the top-level ``openapi`` field to ``"3.2.0"``.
|
||||
4. Materializes the :data:`ServerStreamEvent` discriminated union as
|
||||
a top-level entry under ``components.schemas`` so SSE responses
|
||||
can ``$ref`` it.
|
||||
5. Rewrites the ``text/event-stream`` content entries on the SSE
|
||||
routes to use the OAS 3.2 ``itemSchema`` keyword in place of
|
||||
FastAPI's 3.1 ``schema`` keyword.
|
||||
6. Writes the result to ``openapi.json`` at the repo root.
|
||||
|
||||
Run with no arguments to (re)generate the file. Pass ``--check``
|
||||
in CI to verify the on-disk file is up to date — non-zero exit
|
||||
means a developer changed the spec without regenerating.
|
||||
|
||||
Usage::
|
||||
|
||||
python scripts/dump_openapi.py # write openapi.json
|
||||
python scripts/dump_openapi.py --check # exit 1 if drifted
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# DBOS's ``compute_app_version`` calls ``hashlib.md5()`` without
|
||||
# ``usedforsecurity=False`` for a non-security content hash, which
|
||||
# raises ``ValueError`` on FIPS-enabled hosts. Patch md5 here BEFORE
|
||||
# any DBOS import so both this script and the drift test
|
||||
# (``tests/server/test_openapi_drift.py``, which imports
|
||||
# ``generate_spec`` from this module) work on FIPS hosts. The flag is
|
||||
# the standard Python 3.9+ way to opt non-security md5 calls out of
|
||||
# the FIPS gate; on non-FIPS hosts it's a harmless no-op.
|
||||
_orig_md5 = hashlib.md5
|
||||
|
||||
|
||||
def _fips_safe_md5(*args: Any, **kwargs: Any) -> Any:
|
||||
kwargs.setdefault("usedforsecurity", False)
|
||||
return _orig_md5(*args, **kwargs)
|
||||
|
||||
|
||||
hashlib.md5 = _fips_safe_md5
|
||||
|
||||
from pydantic import TypeAdapter # noqa: E402 — must follow md5 patch
|
||||
|
||||
# ── Module-level constants (rule 34) ──────────────────────────────
|
||||
|
||||
# Output path. The spec lives at the repo root so external tooling
|
||||
# (Stoplight, openapi-generator, redocly, …) can pick it up via a
|
||||
# stable URL relative to the project. Pinned absolute via
|
||||
# ``Path(__file__).resolve().parent.parent`` so the script works
|
||||
# regardless of CWD.
|
||||
_REPO_ROOT: Path = Path(__file__).resolve().parent.parent
|
||||
_OPENAPI_OUT: Path = _REPO_ROOT / "openapi.json"
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
|
||||
# OpenAPI 3.2.0 release: 2025-09-23. We pin the patch version so
|
||||
# the post-processed doc declares its target spec unambiguously.
|
||||
_OPENAPI_VERSION: str = "3.2.0"
|
||||
|
||||
# Routes that emit Server-Sent Events. Each tuple is
|
||||
# ``(path, method)`` keyed exactly as the OpenAPI ``paths`` map
|
||||
# stores them. If the route inventory grows (e.g. a new SSE
|
||||
# endpoint), add the entry here so post-processing rewrites it.
|
||||
_SSE_ROUTES: list[tuple[str, str]] = [
|
||||
("/v1/responses", "post"),
|
||||
("/v1/sessions/{session_id}/stream", "get"),
|
||||
]
|
||||
|
||||
# ── Document-level enrichment ─────────────────────────────────────
|
||||
#
|
||||
# FastAPI emits accurate per-operation schemas but none of the
|
||||
# document-level metadata an integrator needs: no ``servers``, no auth
|
||||
# description, no ``info.description``, and only bare snake_case tags.
|
||||
# We inject that connective tissue here so the published reference
|
||||
# (rendered by Scalar on the omnigent website) is usable for building
|
||||
# an integration. Keeping it in this script — rather than scattering it
|
||||
# across the route decorators — confines presentation concerns to the
|
||||
# spec-generation layer, and the drift test
|
||||
# (``tests/server/test_openapi_drift.py``) guards the result.
|
||||
|
||||
# Self-hosted base URL. ``omnigent server`` binds 127.0.0.1:6767 by
|
||||
# default (see ``_DEFAULT_LOCAL_PORT`` in
|
||||
# ``omnigent/host/local_server.py``).
|
||||
_SERVERS: list[dict[str, str]] = [
|
||||
{
|
||||
"url": "http://127.0.0.1:6767",
|
||||
"description": "Self-hosted Omnigent server (default local port).",
|
||||
},
|
||||
]
|
||||
|
||||
# Markdown prose shown at the top of the rendered reference. Covers
|
||||
# what the API is, the self-hosted base URL, and the deployment-driven
|
||||
# auth model (there is no bearer/API-key scheme — see
|
||||
# ``omnigent/server/auth.py``).
|
||||
_INFO_DESCRIPTION: str = """\
|
||||
Omnigent is an open-source meta-harness for building and running AI \
|
||||
agents. This is the REST API exposed by the Omnigent server: use it to \
|
||||
create and drive **sessions**, manage **agents**, **hosts**, and \
|
||||
**runners**, attach **contextual policies**, post **comments**, and work \
|
||||
with session **resources** — files, terminals, and sandboxed \
|
||||
environments.
|
||||
|
||||
## Base URL
|
||||
|
||||
Omnigent is self-hosted. The server binds `http://127.0.0.1:6767` by \
|
||||
default (`omnigent server`); point the base URL at your own deployment.
|
||||
|
||||
## Authentication
|
||||
|
||||
There is no API-key or bearer-token scheme. Identity is supplied by the \
|
||||
deployment's configured auth provider (`OMNIGENT_AUTH_PROVIDER`):
|
||||
|
||||
- **Trusted proxy header** (default) — an upstream proxy injects an \
|
||||
identity header (`X-Forwarded-Email`, configurable). Single-user local \
|
||||
runtimes fall back to a reserved `local` user.
|
||||
- **Session cookie** — a signed session cookie minted after an \
|
||||
interactive OIDC or accounts login. It is named `ap_session` over HTTP \
|
||||
(the advertised local default) and `__Host-ap_session` under HTTPS, where \
|
||||
the `__Host-` prefix guards against subdomain cookie-tossing.
|
||||
|
||||
Auth is configured server-side; clients send the cookie or proxy header \
|
||||
according to your deployment.
|
||||
|
||||
## Streaming
|
||||
|
||||
`GET /v1/sessions/{session_id}/stream` streams Server-Sent Events \
|
||||
(`text/event-stream`). Each event conforms to the `ServerStreamEvent` \
|
||||
schema documented below.
|
||||
"""
|
||||
|
||||
# Auth representations. Omnigent has no bearer/API-key scheme — identity
|
||||
# arrives via a trusted-proxy header or a signed session cookie,
|
||||
# selected by ``OMNIGENT_AUTH_PROVIDER``. We model both as OpenAPI
|
||||
# ``apiKey`` schemes so SDK generators and the reference can surface
|
||||
# them. We deliberately do NOT assert a top-level ``security``
|
||||
# requirement: the active scheme is deployment-specific, and public
|
||||
# endpoints (``/health``, ``/api/version``) require none — the prose in
|
||||
# :data:`_INFO_DESCRIPTION` carries the human-facing explanation.
|
||||
_SECURITY_SCHEMES: dict[str, dict[str, str]] = {
|
||||
"proxyHeaderAuth": {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-Forwarded-Email",
|
||||
"description": (
|
||||
"Trusted-proxy identity header (header-auth mode, the "
|
||||
"default). The header name is configurable via "
|
||||
"``OMNIGENT_AUTH_HEADER``."
|
||||
),
|
||||
},
|
||||
"sessionCookieAuth": {
|
||||
"type": "apiKey",
|
||||
"in": "cookie",
|
||||
# Named to match the advertised HTTP server. The ``__Host-``
|
||||
# prefix requires HTTPS (browsers drop it on plain HTTP), so the
|
||||
# cookie is ``ap_session`` for the default local deployment and
|
||||
# ``__Host-ap_session`` only under HTTPS — see ``secure_cookies``
|
||||
# in ``accounts_config.py`` / ``oidc.py``.
|
||||
"name": "ap_session",
|
||||
"description": (
|
||||
"Signed session cookie minted after an interactive OIDC or "
|
||||
"accounts login (oidc / accounts auth modes). Named "
|
||||
"``ap_session`` over HTTP (the advertised local default); "
|
||||
"under HTTPS the secure ``__Host-ap_session`` prefixed form "
|
||||
"is used instead."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
# Tag display metadata: human descriptions + sidebar order. Each
|
||||
# ``name`` MUST match the tag FastAPI puts on operations (the route
|
||||
# decorators use these snake_case values). ``x-displayName`` gives docs
|
||||
# tooling a readable label in place of the raw tag. Order here is the
|
||||
# order tags render in the reference sidebar.
|
||||
#
|
||||
# This intentionally covers only the stub-build surface that
|
||||
# ``generate_spec()`` emits: the ``terminals`` router is WebSocket-only
|
||||
# (no HTTP operations in the spec) and the ``auth`` router is mounted
|
||||
# only when an auth provider with a ``login_url`` is configured (absent
|
||||
# in the stub build). If either ever surfaces HTTP operations here, add
|
||||
# its tag below so the operation doesn't render without a description.
|
||||
_TAGS: list[dict[str, str]] = [
|
||||
{
|
||||
"name": "sessions",
|
||||
"x-displayName": "Sessions",
|
||||
"description": (
|
||||
"Create, inspect, fork, and drive agent sessions — the core "
|
||||
"unit of work. Covers session items and events, agent "
|
||||
"binding, permissions, labels, and child sessions. The "
|
||||
"files, terminals, and sandboxed environments attached to a "
|
||||
"session live under Session Resources."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "session_resources",
|
||||
"x-displayName": "Session Resources",
|
||||
"description": (
|
||||
"Files, terminals, and sandboxed environments attached to a "
|
||||
"session: upload and read files, create and manage "
|
||||
"terminals, and read, write, edit, and search the "
|
||||
"environment filesystem."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "session_mcp_servers",
|
||||
"x-displayName": "Session MCP Servers",
|
||||
"description": (
|
||||
"Manage the MCP server declarations on a session's bound "
|
||||
"agent: list the configured servers and create, update, and "
|
||||
"remove them on session-scoped agents."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "agents",
|
||||
"x-displayName": "Agents",
|
||||
"description": "Discover the built-in agents available to bind to a session.",
|
||||
},
|
||||
{
|
||||
"name": "hosts",
|
||||
"x-displayName": "Hosts",
|
||||
"description": (
|
||||
"Hosts that can launch runners. Browse the host filesystem and create directories."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "runners",
|
||||
"x-displayName": "Runners",
|
||||
"description": "Launch runners on a host and check their status.",
|
||||
},
|
||||
{
|
||||
"name": "session_policies",
|
||||
"x-displayName": "Session Policies",
|
||||
"description": (
|
||||
"Contextual policies scoped to a single session — list, create, update, and remove."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "default_policies",
|
||||
"x-displayName": "Default Policies",
|
||||
"description": "Server-level default policies applied to new sessions.",
|
||||
},
|
||||
{
|
||||
"name": "policy_registry",
|
||||
"x-displayName": "Policy Registry",
|
||||
"description": "The catalog of policy types available to instantiate.",
|
||||
},
|
||||
{
|
||||
"name": "comments",
|
||||
"x-displayName": "Comments",
|
||||
"description": (
|
||||
"Threaded comments on a session, including sending a comment to the agent."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "system",
|
||||
"x-displayName": "System",
|
||||
"description": "Health, version, and identity endpoints for the running server.",
|
||||
},
|
||||
]
|
||||
|
||||
# Utility endpoints FastAPI leaves untagged. We assign them a synthetic
|
||||
# ``system`` tag so they group cleanly in the reference instead of
|
||||
# floating in an unlabeled "default" bucket. Keyed ``(path, method)``
|
||||
# like :data:`_SSE_ROUTES`; keep accurate if the route inventory grows.
|
||||
_SYSTEM_ROUTES: list[tuple[str, str]] = [
|
||||
("/health", "get"),
|
||||
("/api/version", "get"),
|
||||
("/v1/info", "get"),
|
||||
("/v1/me", "get"),
|
||||
]
|
||||
|
||||
# HTTP methods that denote an operation object inside a path item
|
||||
# (everything else under a path — ``parameters``, ``servers``, … — is
|
||||
# not an operation and must be skipped when retagging).
|
||||
_HTTP_METHODS: frozenset[str] = frozenset(
|
||||
{"get", "put", "post", "delete", "patch", "options", "head", "trace"},
|
||||
)
|
||||
|
||||
# Path prefix whose operations form the dedicated "Session Resources"
|
||||
# group. The sessions router is mounted with ``tags=["sessions"]`` in
|
||||
# app.py, so every session route — including the resource subtree —
|
||||
# inherits that single tag. We split this subtree (files, terminals,
|
||||
# sandboxed environments) into its own section in the published
|
||||
# reference rather than fracturing the router.
|
||||
_SESSION_RESOURCES_PREFIX: str = "/v1/sessions/{session_id}/resources"
|
||||
|
||||
|
||||
def _build_app_with_stub_stores() -> Any:
|
||||
"""
|
||||
Build a FastAPI app with stub stores sufficient for OpenAPI generation.
|
||||
|
||||
``app.openapi()`` walks the route table and Pydantic models — it
|
||||
does not call any store methods. We use the SQLite-backed
|
||||
implementations against an on-disk temporary database. The temp
|
||||
file is best-effort cleaned up by the caller's filesystem.
|
||||
|
||||
:returns: A configured :class:`fastapi.FastAPI` app.
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
from omnigent.runtime.agent_cache import AgentCache
|
||||
from omnigent.server.app import create_app
|
||||
from omnigent.stores.agent_store.sqlalchemy_store import SqlAlchemyAgentStore
|
||||
from omnigent.stores.artifact_store.local import LocalArtifactStore
|
||||
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.host_store import HostStore
|
||||
from omnigent.stores.policy_store.sqlalchemy_store import SqlAlchemyPolicyStore
|
||||
|
||||
# On-disk SQLite (mkdtemp ensures uniqueness so concurrent
|
||||
# invocations don't collide).
|
||||
workdir = Path(tempfile.mkdtemp(prefix="oa-openapi-"))
|
||||
db_path = workdir / "spec.sqlite"
|
||||
db_uri = f"sqlite:///{db_path}"
|
||||
artifact_store = LocalArtifactStore(str(workdir / "artifacts"))
|
||||
return create_app(
|
||||
agent_store=SqlAlchemyAgentStore(db_uri),
|
||||
file_store=SqlAlchemyFileStore(db_uri),
|
||||
conversation_store=SqlAlchemyConversationStore(db_uri),
|
||||
comment_store=SqlAlchemyCommentStore(db_uri),
|
||||
artifact_store=artifact_store,
|
||||
agent_cache=AgentCache(
|
||||
artifact_store=artifact_store,
|
||||
cache_dir=workdir / "cache",
|
||||
),
|
||||
# Pass stores so conditionally-mounted routes stay in the spec.
|
||||
host_store=HostStore(db_uri),
|
||||
policy_store=SqlAlchemyPolicyStore(db_uri),
|
||||
)
|
||||
|
||||
|
||||
def _server_stream_event_schema() -> dict[str, Any]:
|
||||
"""
|
||||
Return the JSON-Schema dict for the ``ServerStreamEvent`` union.
|
||||
|
||||
Pydantic's ``TypeAdapter.json_schema(ref_template=...)`` emits a
|
||||
schema with internal ``$ref`` pointers in OpenAPI's expected
|
||||
``#/components/schemas/<name>`` form. We then split out the
|
||||
union-root schema and inline the variant definitions into the
|
||||
components map so each per-event class appears as a top-level
|
||||
component schema.
|
||||
|
||||
:returns: A dict with two keys:
|
||||
|
||||
* ``"root"`` — the discriminated-union schema (the value
|
||||
assigned to ``components.schemas.ServerStreamEvent``).
|
||||
* ``"definitions"`` — the per-variant component schemas
|
||||
(merged into ``components.schemas``).
|
||||
"""
|
||||
from omnigent.server.schemas import ServerStreamEvent
|
||||
|
||||
adapter: TypeAdapter[ServerStreamEvent] = TypeAdapter(ServerStreamEvent)
|
||||
schema = adapter.json_schema(ref_template="#/components/schemas/{model}")
|
||||
# Pydantic returns ``{"oneOf": [...], "discriminator": {...},
|
||||
# "$defs": {...}}``. We hoist ``$defs`` to top-level component
|
||||
# schemas and keep the rest as the union root.
|
||||
definitions = schema.pop("$defs", {})
|
||||
return {"root": schema, "definitions": definitions}
|
||||
|
||||
|
||||
def _rewrite_sse_route(
|
||||
paths: dict[str, Any],
|
||||
path: str,
|
||||
method: str,
|
||||
) -> None:
|
||||
"""
|
||||
Rewrite one SSE route's ``text/event-stream`` content for OAS 3.2.
|
||||
|
||||
FastAPI emits ``content: {text/event-stream: {schema: <ref>}}``;
|
||||
OAS 3.2 uses ``itemSchema`` for sequential media types so each
|
||||
event in the stream is described as one item. We rename the key.
|
||||
|
||||
No-op if the route doesn't exist (e.g. a renamed endpoint that
|
||||
fell off the inventory) — the caller's job is to keep
|
||||
:data:`_SSE_ROUTES` accurate.
|
||||
|
||||
:param paths: The OpenAPI ``paths`` map; mutated in place.
|
||||
:param path: Route path, e.g. ``"/v1/responses"``.
|
||||
:param method: HTTP method (lowercase), e.g. ``"post"``.
|
||||
"""
|
||||
op = paths.get(path, {}).get(method)
|
||||
if op is None:
|
||||
return
|
||||
ok_response = op.get("responses", {}).get("200", {})
|
||||
content = ok_response.get("content", {})
|
||||
sse_entry = content.get("text/event-stream")
|
||||
if sse_entry is None:
|
||||
return
|
||||
# Rename ``schema`` → ``itemSchema``. The value (a ``$ref``) is
|
||||
# untouched because the union schema applies to each event
|
||||
# equally — itemSchema is "validate this against every item
|
||||
# in the stream" per the 3.2 spec.
|
||||
if "schema" in sse_entry:
|
||||
sse_entry["itemSchema"] = sse_entry.pop("schema")
|
||||
|
||||
|
||||
def _tag_system_routes(paths: dict[str, Any]) -> None:
|
||||
"""
|
||||
Assign the synthetic ``system`` tag to untagged utility routes.
|
||||
|
||||
FastAPI leaves ``/health``, ``/api/version``, ``/v1/info``, and
|
||||
``/v1/me`` untagged. Without a tag they render in an unlabeled
|
||||
"default" bucket in the reference; tagging them groups the lot
|
||||
under "System". Only fills in a tag where none exists — never
|
||||
overrides one FastAPI already set.
|
||||
|
||||
No-op for any ``(path, method)`` not present, so
|
||||
:data:`_SYSTEM_ROUTES` stays resilient to inventory changes.
|
||||
|
||||
:param paths: The OpenAPI ``paths`` map; mutated in place.
|
||||
"""
|
||||
for path, method in _SYSTEM_ROUTES:
|
||||
op = paths.get(path, {}).get(method)
|
||||
if op is None:
|
||||
continue
|
||||
if not op.get("tags"):
|
||||
op["tags"] = ["system"]
|
||||
|
||||
|
||||
def _retag_session_resources(paths: dict[str, Any]) -> None:
|
||||
"""
|
||||
Move the session-resource subtree into its own ``session_resources`` tag.
|
||||
|
||||
Every operation whose path starts with
|
||||
:data:`_SESSION_RESOURCES_PREFIX` has its tag list *replaced* (not
|
||||
appended) with ``["session_resources"]`` so it renders as a
|
||||
dedicated section instead of inheriting the broad ``sessions`` tag.
|
||||
Prefix-based so newly added resource endpoints group automatically.
|
||||
|
||||
:param paths: The OpenAPI ``paths`` map; mutated in place.
|
||||
"""
|
||||
for path, methods in paths.items():
|
||||
if not path.startswith(_SESSION_RESOURCES_PREFIX):
|
||||
continue
|
||||
for method, op in methods.items():
|
||||
if method in _HTTP_METHODS and isinstance(op, dict):
|
||||
op["tags"] = ["session_resources"]
|
||||
|
||||
|
||||
# ── reStructuredText docstring → Markdown ─────────────────────────
|
||||
#
|
||||
# FastAPI uses each route handler's docstring verbatim as the OpenAPI
|
||||
# operation ``description``. Our docstrings are Sphinx/reST: ``:param
|
||||
# name:`` / ``:returns:`` / ``:raises Exc:`` field lists and inline
|
||||
# ``:class:`Foo``` cross-reference roles. Docs renderers (Scalar) treat
|
||||
# the description as Markdown, so reST field lists collapse into one
|
||||
# unreadable run of literal text. We convert that markup to Markdown:
|
||||
#
|
||||
# * each ``:param name:`` whose name matches a real query/path
|
||||
# parameter is moved onto that parameter's ``description`` (so it
|
||||
# renders inline in the parameter table, not in the prose blob);
|
||||
# * request-body / form ``:param`` entries that have no matching
|
||||
# parameter become a Markdown ``**Parameters**`` bullet list;
|
||||
# * ``:returns:`` becomes a ``**Returns:**`` line, ``:raises:`` a
|
||||
# ``**Raises**`` bullet list;
|
||||
# * framework-internal params (``request``/``response``/…) are dropped;
|
||||
# * inline ``:role:`X``` roles collapse to `` `X` `` and reST double
|
||||
# backticks (`` ``X`` ``) normalize to Markdown single backticks.
|
||||
|
||||
# Field-list line markers (matched at column 0; continuation lines are
|
||||
# indented and accumulate onto the field opened above them).
|
||||
_RST_PARAM = re.compile(r"^:(?:param|parameter|arg|argument|keyword|kwarg)\s+(\S+)\s*:\s*(.*)$")
|
||||
_RST_RETURNS = re.compile(r"^:returns?\s*:\s*(.*)$")
|
||||
_RST_RAISES = re.compile(r"^:raises?\s+([^:]+?)\s*:\s*(.*)$")
|
||||
# Any other reST field marker (``:rtype:``, ``:type x:``, …) — dropped.
|
||||
_RST_OTHER_FIELD = re.compile(r"^:[a-zA-Z][\w ]*:")
|
||||
# Inline cross-reference role, e.g. ``:class:`Foo``` → `` `Foo` ``.
|
||||
_RST_ROLE = re.compile(r":[a-zA-Z]+:`([^`]+)`")
|
||||
# reST inline literal (double backtick) → Markdown code span (single).
|
||||
# Non-greedy + DOTALL so a literal may span lines and contain nested
|
||||
# single backticks (e.g. a role left inside it); the replacement flattens
|
||||
# those so the resulting code span is valid.
|
||||
_RST_DOUBLE_BACKTICK = re.compile(r"``(.+?)``", re.DOTALL)
|
||||
|
||||
# Handler parameters that are FastAPI plumbing, not API inputs.
|
||||
_INTERNAL_PARAMS = frozenset(
|
||||
{"request", "response", "websocket", "ws", "background_tasks", "bg", "_", "args", "kwargs"},
|
||||
)
|
||||
|
||||
|
||||
def _rst_double_backtick_to_code(match: re.Match[str]) -> str:
|
||||
"""Flatten a reST ``literal`` into a single-line Markdown code span."""
|
||||
inner = re.sub(r"\s+", " ", match.group(1).replace("`", "")).strip()
|
||||
return f"`{inner}`"
|
||||
|
||||
|
||||
def _rst_inline_to_md(text: str) -> str:
|
||||
"""Convert inline reST roles / literals in *text* to Markdown."""
|
||||
text = _RST_ROLE.sub(r"`\1`", text)
|
||||
return _RST_DOUBLE_BACKTICK.sub(_rst_double_backtick_to_code, text)
|
||||
|
||||
|
||||
def _rst_field_text(lines: list[str]) -> str:
|
||||
"""Join a field's (possibly multi-line) body into one Markdown string."""
|
||||
joined = re.sub(r"\s+", " ", " ".join(lines)).strip()
|
||||
return _rst_inline_to_md(joined)
|
||||
|
||||
|
||||
def _parse_rst_doc(desc: str) -> tuple[str, list[tuple[str, str | None, str]]]:
|
||||
"""
|
||||
Split a reST docstring into Markdown prose and parsed fields.
|
||||
|
||||
Lines before the first reST field marker are prose; ``:param:`` /
|
||||
``:returns:`` / ``:raises:`` open a field that subsequent indented
|
||||
continuation lines accumulate onto. Unknown field markers (e.g.
|
||||
``:rtype:``) are discarded.
|
||||
|
||||
:param desc: The raw (reST) description text.
|
||||
:returns: ``(prose_markdown, fields)`` where ``fields`` is a list of
|
||||
``(kind, name, text)`` triples (``kind`` in ``param`` /
|
||||
``returns`` / ``raises``) with ``text`` already Markdown.
|
||||
"""
|
||||
prose: list[str] = []
|
||||
fields: list[tuple[str, str | None, list[str]]] = []
|
||||
cur: tuple[str, str | None, list[str]] | None = None
|
||||
in_fields = False
|
||||
for line in desc.split("\n"):
|
||||
param_m = _RST_PARAM.match(line)
|
||||
if param_m:
|
||||
in_fields = True
|
||||
cur = ("param", param_m.group(1).strip().lstrip("*"), [param_m.group(2)])
|
||||
fields.append(cur)
|
||||
continue
|
||||
returns_m = _RST_RETURNS.match(line)
|
||||
if returns_m:
|
||||
in_fields = True
|
||||
cur = ("returns", None, [returns_m.group(1)])
|
||||
fields.append(cur)
|
||||
continue
|
||||
raises_m = _RST_RAISES.match(line)
|
||||
if raises_m:
|
||||
in_fields = True
|
||||
cur = ("raises", raises_m.group(1).strip(), [raises_m.group(2)])
|
||||
fields.append(cur)
|
||||
continue
|
||||
if in_fields and _RST_OTHER_FIELD.match(line):
|
||||
cur = ("drop", None, []) # unknown field (e.g. :rtype:) — discard
|
||||
fields.append(cur)
|
||||
continue
|
||||
if in_fields:
|
||||
if cur is not None:
|
||||
cur[2].append(line)
|
||||
else:
|
||||
prose.append(line)
|
||||
|
||||
prose_md = _rst_inline_to_md("\n".join(prose).strip())
|
||||
parsed = [(kind, name, _rst_field_text(body)) for kind, name, body in fields if kind != "drop"]
|
||||
return prose_md, parsed
|
||||
|
||||
|
||||
def _reformat_doc(
|
||||
desc: str | None,
|
||||
targets: dict[str, Any],
|
||||
internal: frozenset[str] | None = None,
|
||||
) -> str | None:
|
||||
"""
|
||||
Convert one reST ``description`` to Markdown.
|
||||
|
||||
Each ``:param name:`` whose ``name`` is a key in *targets* (a
|
||||
parameter or property object) is moved onto that object's own
|
||||
``description``; entries with no matching target become a Markdown
|
||||
``**Parameters**`` list. ``:returns:`` / ``:raises:`` become
|
||||
``**Returns:**`` / ``**Raises**`` sections. Names in *internal*
|
||||
(FastAPI plumbing) are dropped.
|
||||
|
||||
:param desc: The raw description, or ``None``.
|
||||
:param targets: Map of name -> object that may receive a moved
|
||||
``description`` (empty when there are no field targets).
|
||||
:param internal: Parameter names to drop entirely (``None`` = drop
|
||||
none, used for schema fields).
|
||||
:returns: The rebuilt Markdown description, or the original falsy
|
||||
value when *desc* is empty.
|
||||
"""
|
||||
if not desc:
|
||||
return desc
|
||||
skip = internal or frozenset()
|
||||
prose_md, fields = _parse_rst_doc(desc)
|
||||
body_params: list[tuple[str, str]] = []
|
||||
raises: list[tuple[str, str]] = []
|
||||
returns: str | None = None
|
||||
for kind, name, text in fields:
|
||||
if kind == "param":
|
||||
if not text or name in skip:
|
||||
continue
|
||||
target = targets.get(name) if name else None
|
||||
if isinstance(target, dict):
|
||||
# Move onto the matching field; don't clobber an explicit
|
||||
# Field/Query description if one already exists.
|
||||
if not target.get("description"):
|
||||
target["description"] = text
|
||||
else:
|
||||
body_params.append((name or "", text))
|
||||
elif kind == "raises" and text:
|
||||
raises.append((name or "", text))
|
||||
elif kind == "returns" and text:
|
||||
returns = text
|
||||
|
||||
sections: list[str] = []
|
||||
if prose_md:
|
||||
sections.append(prose_md)
|
||||
if body_params:
|
||||
sections.append("**Parameters**\n\n" + "\n".join(f"- `{n}` — {t}" for n, t in body_params))
|
||||
if returns:
|
||||
sections.append(f"**Returns:** {returns}")
|
||||
if raises:
|
||||
sections.append("**Raises**\n\n" + "\n".join(f"- `{e}` — {t}" for e, t in raises))
|
||||
return "\n\n".join(sections)
|
||||
|
||||
|
||||
def _reformat_operation_doc(op: dict[str, Any]) -> None:
|
||||
"""
|
||||
Rewrite an operation's (and its responses') reST docs as Markdown.
|
||||
|
||||
Matched ``:param:`` entries move onto ``op['parameters']``; response
|
||||
descriptions are reformatted with no field targets.
|
||||
|
||||
:param op: An OpenAPI operation object; mutated in place.
|
||||
"""
|
||||
if op.get("description"):
|
||||
targets = {p.get("name"): p for p in op.get("parameters", []) if isinstance(p, dict)}
|
||||
op["description"] = _reformat_doc(op["description"], targets, _INTERNAL_PARAMS)
|
||||
for resp in (op.get("responses") or {}).values():
|
||||
if isinstance(resp, dict) and resp.get("description"):
|
||||
resp["description"] = _reformat_doc(resp["description"], {})
|
||||
|
||||
|
||||
def _reformat_schema_node(node: Any) -> None:
|
||||
"""
|
||||
Rewrite a JSON-Schema node's reST ``description`` as Markdown.
|
||||
|
||||
A model's docstring becomes its schema ``description`` with
|
||||
``:param name:`` entries describing its fields; each moves onto the
|
||||
matching ``properties[name]`` description. Recurses into nested
|
||||
schema positions so inline sub-objects are handled too.
|
||||
|
||||
:param node: A JSON-Schema object (non-dicts are ignored); mutated
|
||||
in place.
|
||||
"""
|
||||
if not isinstance(node, dict):
|
||||
return
|
||||
if node.get("description"):
|
||||
props = node.get("properties")
|
||||
node["description"] = _reformat_doc(
|
||||
node["description"],
|
||||
props if isinstance(props, dict) else {},
|
||||
)
|
||||
properties = node.get("properties")
|
||||
if isinstance(properties, dict):
|
||||
for sub in properties.values():
|
||||
_reformat_schema_node(sub)
|
||||
for defs_key in ("$defs", "definitions"):
|
||||
defs = node.get(defs_key)
|
||||
if isinstance(defs, dict):
|
||||
for sub in defs.values():
|
||||
_reformat_schema_node(sub)
|
||||
for child_key in ("items", "additionalProperties"):
|
||||
_reformat_schema_node(node.get(child_key))
|
||||
for combinator in ("allOf", "anyOf", "oneOf", "prefixItems"):
|
||||
members = node.get(combinator)
|
||||
if isinstance(members, list):
|
||||
for sub in members:
|
||||
_reformat_schema_node(sub)
|
||||
|
||||
|
||||
def _reformat_descriptions(paths: dict[str, Any]) -> None:
|
||||
"""Convert every operation's reST description to Markdown in place."""
|
||||
for methods in paths.values():
|
||||
for method, op in methods.items():
|
||||
if method in _HTTP_METHODS and isinstance(op, dict):
|
||||
_reformat_operation_doc(op)
|
||||
|
||||
|
||||
def _reformat_component_schemas(components: dict[str, Any]) -> None:
|
||||
"""Convert every component schema's reST description to Markdown."""
|
||||
schemas = components.get("schemas")
|
||||
if isinstance(schemas, dict):
|
||||
for schema in schemas.values():
|
||||
_reformat_schema_node(schema)
|
||||
|
||||
|
||||
def _normalize_inline_descriptions(node: Any) -> None:
|
||||
"""
|
||||
Final safety net: normalize inline reST in any remaining description.
|
||||
|
||||
Walks the whole document and converts inline ``:role:`X``` roles and
|
||||
reST double-backtick literals to Markdown `` `X` `` in every
|
||||
``description`` string — covering responses, ``info``, tags and
|
||||
security schemes that the structured passes don't rewrite.
|
||||
|
||||
:param node: Any spec fragment; mutated in place.
|
||||
"""
|
||||
if isinstance(node, dict):
|
||||
for key, value in node.items():
|
||||
if key == "description" and isinstance(value, str):
|
||||
node[key] = _rst_inline_to_md(value)
|
||||
else:
|
||||
_normalize_inline_descriptions(value)
|
||||
elif isinstance(node, list):
|
||||
for value in node:
|
||||
_normalize_inline_descriptions(value)
|
||||
|
||||
|
||||
def _enrich_spec(spec: dict[str, Any]) -> None:
|
||||
"""
|
||||
Inject document-level metadata for docs / SDK tooling.
|
||||
|
||||
Adds ``info.description``, ``servers``, top-level ``tags`` with
|
||||
human-readable descriptions, and ``components.securitySchemes`` —
|
||||
none of which FastAPI emits — tags the untagged utility routes, and
|
||||
rewrites reST docstrings (operations, parameters, and component
|
||||
schemas) as Markdown.
|
||||
Mutates ``spec`` in place. See the module-level enrichment
|
||||
constants for the rationale behind each value.
|
||||
|
||||
:param spec: The generated OpenAPI dict; mutated in place.
|
||||
"""
|
||||
info = spec.setdefault("info", {})
|
||||
info["description"] = _INFO_DESCRIPTION
|
||||
spec["servers"] = _SERVERS
|
||||
spec["tags"] = _TAGS
|
||||
|
||||
components = spec.setdefault("components", {})
|
||||
components["securitySchemes"] = _SECURITY_SCHEMES
|
||||
|
||||
paths = spec.setdefault("paths", {})
|
||||
_tag_system_routes(paths)
|
||||
_retag_session_resources(paths)
|
||||
_reformat_descriptions(paths)
|
||||
_reformat_component_schemas(components)
|
||||
_normalize_inline_descriptions(spec)
|
||||
|
||||
|
||||
def generate_spec() -> dict[str, Any]:
|
||||
"""
|
||||
Build, generate, and post-process the OpenAPI 3.2 spec.
|
||||
|
||||
Encapsulates every step (app construction, generation, version
|
||||
bump, schema injection, SSE rewrite) so callers can compare the
|
||||
generated dict against ``openapi.json`` without writing to disk.
|
||||
|
||||
:returns: The post-processed OpenAPI dict, ready to serialize.
|
||||
"""
|
||||
app = _build_app_with_stub_stores()
|
||||
spec = app.openapi()
|
||||
# Bump the OpenAPI version literal — we don't change any
|
||||
# 3.1-only constructs because FastAPI's emitted shape is also
|
||||
# valid 3.2.x (3.2 is JSON-Schema-aligned and largely additive
|
||||
# over 3.1).
|
||||
spec["openapi"] = _OPENAPI_VERSION
|
||||
|
||||
# Inject the ServerStreamEvent union + per-variant defs into
|
||||
# ``components.schemas`` so the SSE routes' $ref points resolve.
|
||||
components = spec.setdefault("components", {})
|
||||
schemas = components.setdefault("schemas", {})
|
||||
union = _server_stream_event_schema()
|
||||
schemas["ServerStreamEvent"] = union["root"]
|
||||
for name, definition in union["definitions"].items():
|
||||
# Don't clobber a same-named schema FastAPI already
|
||||
# synthesized — the union's per-variant defs include
|
||||
# ``ResponseObject`` (referenced from terminal events), and
|
||||
# FastAPI also emits one. Keep FastAPI's version; the
|
||||
# serialized shape is identical for our models.
|
||||
schemas.setdefault(name, definition)
|
||||
|
||||
# Rewrite SSE routes' content entries to use ``itemSchema``.
|
||||
paths = spec.get("paths", {})
|
||||
for path, method in _SSE_ROUTES:
|
||||
_rewrite_sse_route(paths, path, method)
|
||||
|
||||
# Inject document-level metadata (servers, auth, tags, prose) that
|
||||
# FastAPI doesn't emit but docs / SDK tooling needs.
|
||||
_enrich_spec(spec)
|
||||
|
||||
return spec # type: ignore[no-any-return]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""
|
||||
CLI entry point.
|
||||
|
||||
With no arguments, regenerates ``openapi.json``. With
|
||||
``--check``, compares the generated spec to the on-disk file
|
||||
and exits 1 if they differ.
|
||||
|
||||
:returns: 0 on success / no drift, 1 on drift in ``--check``
|
||||
mode.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help=(
|
||||
"CI mode — exit 1 if the on-disk openapi.json differs from "
|
||||
"the generated spec. Use to fail PRs that change the spec "
|
||||
"without regenerating."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
spec = generate_spec()
|
||||
serialized = json.dumps(spec, indent=2, sort_keys=True) + "\n"
|
||||
|
||||
if args.check:
|
||||
if not _OPENAPI_OUT.exists():
|
||||
sys.stderr.write(
|
||||
f"openapi.json not found at {_OPENAPI_OUT}; "
|
||||
"run `python scripts/dump_openapi.py` to generate it.\n",
|
||||
)
|
||||
return 1
|
||||
existing = _OPENAPI_OUT.read_text()
|
||||
if existing != serialized:
|
||||
sys.stderr.write(
|
||||
"openapi.json is out of sync with the generated spec.\n"
|
||||
"Run `python scripts/dump_openapi.py` to regenerate.\n",
|
||||
)
|
||||
return 1
|
||||
sys.stdout.write("openapi.json is up to date.\n")
|
||||
return 0
|
||||
|
||||
_OPENAPI_OUT.write_text(serialized)
|
||||
sys.stdout.write(f"Wrote {_OPENAPI_OUT}\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate ``_CURSOR_BASE_MODELS`` for ``omnigent/cursor_native.py``.
|
||||
|
||||
The cursor-native web picker needs *base* model ids — the namespace that (a)
|
||||
launches via ``cursor-agent --model <id>``, (b) injects via ``/model <id>`` in
|
||||
the TUI, and (c) is what ``meta.lastUsedModel`` reports back to the web mirror.
|
||||
``cursor-agent models`` (== ``--list-models``) instead prints ~80 *compound*
|
||||
ids that flatten ``model x effort`` (``gpt-5.2-high``) and spell the claude
|
||||
4.5/4.6 family with reversed word order + a dotted version
|
||||
(``claude-4.6-opus-high`` vs the canonical ``claude-opus-4-6``).
|
||||
|
||||
This script derives the base-id catalog from that output: it strips the trailing
|
||||
effort/thinking group to recover the base id, applies ``_BASE_ID_OVERRIDES`` for
|
||||
the irregular claude spellings, drops ``_DENYLIST_PREFIXES`` (prefix-collision /
|
||||
unoffered tiers), derives a clean display name, and carries the ``(default)``
|
||||
tag. ``(current)`` is ignored — it is per-session state, not a catalog property.
|
||||
|
||||
Usage::
|
||||
|
||||
cursor-agent models | python scripts/gen_cursor_models.py
|
||||
python scripts/gen_cursor_models.py # shells out to ``cursor-agent models``
|
||||
|
||||
Re-run when cursor ships new models and paste the printed literal into
|
||||
``omnigent/cursor_native.py`` (between the ``# >>> generated`` markers). Review
|
||||
the diff: a brand-new model with a *new* irregular spelling needs a one-line
|
||||
``_BASE_ID_OVERRIDES`` entry, and the script warns when it sees an unmapped
|
||||
claude reordering so the gap never lands silently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# Trailing effort / thinking tokens cursor appends to a base id. Order matters:
|
||||
# multi-word ``extra-high`` must be tried before ``high``. The group repeats so
|
||||
# ``claude-opus-4-8-thinking-high`` and ``claude-4.6-opus-high-thinking`` (the
|
||||
# two orderings cursor uses) both strip fully.
|
||||
_EFFORT_TOKENS = ("extra-high", "thinking", "xhigh", "medium", "high", "low", "none", "max")
|
||||
_EFFORT_SUFFIX_RE = re.compile(r"(?:-(?:" + "|".join(_EFFORT_TOKENS) + r"))+$")
|
||||
|
||||
# Irregular cursor spellings -> canonical base id (verified to inject via
|
||||
# ``/model`` and to match what ``meta.lastUsedModel`` reports). Only the claude
|
||||
# 4.5/4.6 family reverses order + dots the version; 4.7/4.8 already use the
|
||||
# canonical ``claude-opus-4-N`` form and need no override.
|
||||
_BASE_ID_OVERRIDES: dict[str, str] = {
|
||||
"claude-4.6-opus": "claude-opus-4-6",
|
||||
"claude-4.6-sonnet": "claude-sonnet-4-6",
|
||||
"claude-4.5-opus": "claude-opus-4-5",
|
||||
"claude-4.5-sonnet": "claude-sonnet-4-5",
|
||||
}
|
||||
|
||||
# Base-id prefixes to exclude from the picker, each with its reason. Matched
|
||||
# against the *derived* base id, so a prefix kills a whole family.
|
||||
_DENYLIST_PREFIXES: dict[str, str] = {
|
||||
"gpt-5.1": "prefix-collision: /model gpt-5.1 mis-ranks to 'Codex 5.1 Max'",
|
||||
"gpt-5-mini": "low-value tier, not offered in the picker",
|
||||
"gpt-5.4-mini": "low-value tier, not offered in the picker",
|
||||
"gpt-5.4-nano": "low-value tier, not offered in the picker",
|
||||
"gemini-3-flash": "flash tier not offered in the picker",
|
||||
"gemini-3.5-flash": "flash tier not offered in the picker",
|
||||
"claude-4-sonnet": "Sonnet 4: base-id injection not yet verified",
|
||||
}
|
||||
|
||||
# Trailing display-name words that describe effort/context rather than the
|
||||
# model, stripped to recover a clean label ("Opus 4.8 1M Extra High" -> "Opus 4.8").
|
||||
_DISPLAY_STRIP_WORDS = {"1m", "none", "low", "medium", "high", "max", "thinking", "extra"}
|
||||
|
||||
# Family display order (claude opus, claude sonnet, gpt, codex, gemini), with
|
||||
# ``auto`` and the account default pinned to the top.
|
||||
_FAMILY_RANK = {
|
||||
"auto": 0,
|
||||
"composer": 1,
|
||||
"opus": 2,
|
||||
"sonnet": 3,
|
||||
"gpt": 4,
|
||||
"codex": 5,
|
||||
"gemini": 6,
|
||||
}
|
||||
|
||||
_LINE_RE = re.compile(r"^(?P<id>\S+)\s+-\s+(?P<name>.+?)(?:\s+\((?P<tags>[^)]*)\))?$")
|
||||
|
||||
|
||||
def _base_id(compound_id: str) -> str:
|
||||
"""Recover the canonical base id from a compound ``--list-models`` id."""
|
||||
stripped = _EFFORT_SUFFIX_RE.sub("", compound_id)
|
||||
return _BASE_ID_OVERRIDES.get(stripped, stripped)
|
||||
|
||||
|
||||
def _clean_display(name: str) -> str:
|
||||
"""Strip trailing effort/context words from a model's display name."""
|
||||
words = name.split()
|
||||
while words and words[-1].lower() in _DISPLAY_STRIP_WORDS:
|
||||
words.pop()
|
||||
return " ".join(words)
|
||||
|
||||
|
||||
def _family(base_id: str) -> str:
|
||||
if base_id == "auto":
|
||||
return "auto"
|
||||
if base_id.startswith("composer"):
|
||||
return "composer"
|
||||
if "opus" in base_id:
|
||||
return "opus"
|
||||
if "sonnet" in base_id:
|
||||
return "sonnet"
|
||||
if "codex" in base_id:
|
||||
return "codex"
|
||||
if base_id.startswith("gpt"):
|
||||
return "gpt"
|
||||
if base_id.startswith("gemini"):
|
||||
return "gemini"
|
||||
return "other"
|
||||
|
||||
|
||||
def _version(base_id: str) -> tuple[float, ...]:
|
||||
"""Numeric version for descending sort within a family (4.8 before 4.7)."""
|
||||
nums = [int(n) for n in re.findall(r"\d+", base_id)]
|
||||
return tuple(nums) if nums else (0,)
|
||||
|
||||
|
||||
def _denied(base_id: str) -> str | None:
|
||||
for prefix, reason in _DENYLIST_PREFIXES.items():
|
||||
if base_id == prefix or base_id.startswith(prefix + "-"):
|
||||
return reason
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if sys.stdin.isatty():
|
||||
raw = subprocess.run(
|
||||
["cursor-agent", "models"], capture_output=True, text=True, check=True
|
||||
).stdout
|
||||
else:
|
||||
raw = sys.stdin.read()
|
||||
|
||||
# Derive base id -> (display, is_default), first compound variant wins for
|
||||
# the display name; is_default is OR-ed across the family's variants.
|
||||
derived: dict[str, dict[str, object]] = {}
|
||||
for line in raw.splitlines():
|
||||
m = _LINE_RE.match(line.strip())
|
||||
if not m or " " in m.group("id"):
|
||||
continue
|
||||
compound = m.group("id")
|
||||
tags = {t.strip() for t in (m.group("tags") or "").split(",") if t.strip()}
|
||||
base = _base_id(compound)
|
||||
# Warn on an unmapped claude reordering so a new one never lands silently.
|
||||
if base.startswith("claude-4."):
|
||||
print(
|
||||
f"WARNING: unmapped irregular claude id {compound!r} -> {base!r}; "
|
||||
f"add a _BASE_ID_OVERRIDES entry",
|
||||
file=sys.stderr,
|
||||
)
|
||||
entry = derived.setdefault(
|
||||
base, {"display": _clean_display(m.group("name")), "default": False}
|
||||
)
|
||||
if "default" in tags:
|
||||
entry["default"] = True
|
||||
|
||||
rows = []
|
||||
for base, entry in derived.items():
|
||||
reason = _denied(base)
|
||||
if reason:
|
||||
print(f" skip {base:<22} ({reason})", file=sys.stderr)
|
||||
continue
|
||||
rows.append((base, str(entry["display"]), bool(entry["default"])))
|
||||
|
||||
rows.sort(
|
||||
key=lambda r: (_FAMILY_RANK.get(_family(r[0]), 99), tuple(-n for n in _version(r[0])))
|
||||
)
|
||||
|
||||
print("_CURSOR_BASE_MODELS: list[dict[str, Any]] = [")
|
||||
for base, display, is_default in rows:
|
||||
suffix = ', "isDefault": True' if is_default else ""
|
||||
print(f' {{"id": "{base}", "displayName": "{display}"{suffix}}},')
|
||||
print("]")
|
||||
print(f"\n# {len(rows)} models", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+642
@@ -0,0 +1,642 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Omnigent installer.
|
||||
#
|
||||
# Installs the published `omnigent` wheel from PyPI with uv, wires up PATH,
|
||||
# and points you at first-run. The wheel bundles the prebuilt web UI, so the
|
||||
# default install needs no Node/npm and runs no build.
|
||||
#
|
||||
# Options:
|
||||
# --version X install a specific PyPI release (default: latest)
|
||||
# --repo URL install from a git checkout instead (builds from source;
|
||||
# requires Node 22+/npm) — for development
|
||||
# --extra NAME install an optional-dependency extra (repeatable, or
|
||||
# comma-separated), e.g. --extra databricks
|
||||
# --non-interactive, --verbose
|
||||
#
|
||||
# uv and git (only with --repo) are required; the installer offers to install
|
||||
# them if missing. Node/npm are needed by the Claude/Codex/Pi harnesses, tmux
|
||||
# by their terminal launchers, and bubblewrap (Linux only) to OS-sandbox those
|
||||
# terminals — missing ones are warnings, not errors, unless building from
|
||||
# source.
|
||||
|
||||
set -eu
|
||||
|
||||
# Published PyPI package, the default install. --version pins a release.
|
||||
PACKAGE_NAME="omnigent"
|
||||
VERSION=
|
||||
# Comma-separated optional-dependency extras to install with the package
|
||||
# (e.g. "databricks"), accumulated from one or more --extra flags. Empty =>
|
||||
# the base install with no extras.
|
||||
EXTRAS=
|
||||
# Set by --repo to install from a git checkout instead (development; builds
|
||||
# the web UI from source). Empty => install the published wheel from PyPI.
|
||||
REPO_URL=
|
||||
PYTHON_VERSION="3.12"
|
||||
INSTALL_URL=
|
||||
NON_INTERACTIVE=false
|
||||
VERBOSE=false
|
||||
ESC=$(printf '\033')
|
||||
RESET=
|
||||
BOLD=
|
||||
DIM=
|
||||
MAGENTA=
|
||||
GREEN=
|
||||
YELLOW=
|
||||
RED=
|
||||
|
||||
use_terminal_ui() {
|
||||
[ -t 1 ] && [ "${TERM:-}" != "dumb" ]
|
||||
}
|
||||
|
||||
init_style() {
|
||||
if use_terminal_ui && [ -z "${NO_COLOR:-}" ]; then
|
||||
RESET="${ESC}[0m"
|
||||
BOLD="${ESC}[1m"
|
||||
DIM="${ESC}[2m"
|
||||
# Brand accent — Otto's magenta-pink (#F43BA6), matching the Python CLI
|
||||
# palette in omnigent/inner/ui.py so the installer and the tool agree.
|
||||
MAGENTA="${ESC}[38;2;244;59;166m"
|
||||
GREEN="${ESC}[32m"
|
||||
YELLOW="${ESC}[33m"
|
||||
RED="${ESC}[31m"
|
||||
fi
|
||||
}
|
||||
|
||||
# The Otto + "omnigent" wordmark lockup, printed once at the top of an
|
||||
# interactive install. Mirrors omnigent.inner.wordmark.lockup_lines(); the
|
||||
# whole lockup is painted in the brand magenta (flat — no gradient in sh).
|
||||
# Skipped off a TTY (use_terminal_ui) so piped/CI installs stay clean.
|
||||
print_banner() {
|
||||
use_terminal_ui || return 0
|
||||
printf '\n'
|
||||
printf '%s ⠀⠀⠀⢠⣿⡄⠀⠀⠀ ██████╗ ███╗ ███╗███╗ ██╗██╗ ██████╗ ███████╗███╗ ██╗████████╗%s\n' "$MAGENTA" "$RESET"
|
||||
printf '%s ⢴⣶⣶⠉⣿⠉⣶⣶⡦ ██╔═══██╗████╗ ████║████╗ ██║██║██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝%s\n' "$MAGENTA" "$RESET"
|
||||
printf '%s ⠀⠙⣿⣶⣿⣶⣿⠋⠀ ██║ ██║██╔████╔██║██╔██╗ ██║██║██║ ███╗█████╗ ██╔██╗ ██║ ██║%s\n' "$MAGENTA" "$RESET"
|
||||
printf '%s ⠀⢠⣿⡿⠿⢿⣿⡄⠀ ╚██████╔╝██║ ╚═╝ ██║██║ ╚████║██║╚██████╔╝███████╗██║ ╚████║ ██║%s\n' "$MAGENTA" "$RESET"
|
||||
printf '%s ⠀⠈⠁⠀⠀⠀⠈⠁⠀ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝%s\n' "$MAGENTA" "$RESET"
|
||||
printf '%s all your agents, one cli%s\n\n' "$DIM" "$RESET"
|
||||
}
|
||||
|
||||
usage() {
|
||||
printf 'Usage: install_oss.sh [--non-interactive] [--verbose] [--version X] [--repo URL] [--extra NAME]\n'
|
||||
}
|
||||
|
||||
step() {
|
||||
printf '%s==>%s %s\n' "$MAGENTA" "$RESET" "$1"
|
||||
}
|
||||
|
||||
verbose() {
|
||||
if [ "$VERBOSE" = true ]; then
|
||||
printf '%sDEBUG:%s %s\n' "$DIM" "$RESET" "$1" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
warn() {
|
||||
printf '%sWARNING:%s %s\n' "$YELLOW" "$RESET" "$1" >&2
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf '%sERROR:%s %s\n' "$RED" "$RESET" "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
spinner_frame() {
|
||||
case $(($1 % 4)) in
|
||||
0) printf '-' ;;
|
||||
1) printf '%s' "\\" ;;
|
||||
2) printf '|' ;;
|
||||
*) printf '/' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
run_with_spinner() {
|
||||
label="$1"
|
||||
shift
|
||||
|
||||
if [ "$VERBOSE" = true ]; then
|
||||
verbose "Running: $*"
|
||||
"$@"
|
||||
return
|
||||
fi
|
||||
|
||||
if ! use_terminal_ui; then
|
||||
"$@"
|
||||
return
|
||||
fi
|
||||
|
||||
log_file="${TMPDIR:-/tmp}/omnigent-oss-installer.$$.log"
|
||||
status_file="${TMPDIR:-/tmp}/omnigent-oss-installer.$$.status"
|
||||
rm -f "$log_file" "$status_file"
|
||||
|
||||
(
|
||||
if "$@" >"$log_file" 2>&1; then
|
||||
printf '0\n' >"$status_file"
|
||||
else
|
||||
printf '%s\n' "$?" >"$status_file"
|
||||
fi
|
||||
) &
|
||||
command_pid=$!
|
||||
|
||||
frame=0
|
||||
while [ ! -f "$status_file" ]; do
|
||||
spinner="$(spinner_frame "$frame")"
|
||||
printf '\r\033[K%s%s%s %s%s%s' "$MAGENTA" "$spinner" "$RESET" "$BOLD" "$label" "$RESET"
|
||||
frame=$((frame + 1))
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
wait "$command_pid" 2>/dev/null || true
|
||||
status="$(cat "$status_file")"
|
||||
rm -f "$status_file"
|
||||
|
||||
if [ "$status" = 0 ]; then
|
||||
printf '\r\033[K%sok%s %s\n' "$GREEN" "$RESET" "$label"
|
||||
rm -f "$log_file"
|
||||
return
|
||||
fi
|
||||
|
||||
printf '\r\033[K'
|
||||
warn "$label failed"
|
||||
cat "$log_file" >&2
|
||||
rm -f "$log_file"
|
||||
return "$status"
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--non-interactive)
|
||||
NON_INTERACTIVE=true
|
||||
;;
|
||||
--verbose)
|
||||
VERBOSE=true
|
||||
;;
|
||||
--repo)
|
||||
if [ "$#" -lt 2 ]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
REPO_URL="$2"
|
||||
shift
|
||||
;;
|
||||
--version)
|
||||
if [ "$#" -lt 2 ]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
VERSION="$2"
|
||||
shift
|
||||
;;
|
||||
--extra)
|
||||
if [ "$#" -lt 2 ]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
# Accumulate repeated flags into one comma-separated list; a value that
|
||||
# is itself comma-separated (--extra a,b) just concatenates cleanly.
|
||||
if [ -n "$EXTRAS" ]; then
|
||||
EXTRAS="$EXTRAS,$2"
|
||||
else
|
||||
EXTRAS="$2"
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
}
|
||||
|
||||
normalize_repo_url() {
|
||||
case "$REPO_URL" in
|
||||
"")
|
||||
# No --repo: install the published wheel from PyPI (the default).
|
||||
INSTALL_URL=
|
||||
return
|
||||
;;
|
||||
git+ssh://* | git+https://* | git+http://*)
|
||||
INSTALL_URL="$REPO_URL"
|
||||
;;
|
||||
ssh://*)
|
||||
INSTALL_URL="git+$REPO_URL"
|
||||
;;
|
||||
https://* | http://*)
|
||||
INSTALL_URL="git+$REPO_URL"
|
||||
;;
|
||||
*@*:*)
|
||||
user_host="${REPO_URL%%:*}"
|
||||
repo_path="${REPO_URL#*:}"
|
||||
INSTALL_URL="git+ssh://$user_host/$repo_path"
|
||||
;;
|
||||
*)
|
||||
fail "Unsupported --repo URL: $REPO_URL. Use https://..., ssh://..., or git@host:org/repo.git."
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -n "$VERSION" ]; then
|
||||
fail "--version pins a PyPI release and cannot be combined with --repo (a git source install)."
|
||||
fi
|
||||
verbose "Repository install URL: $INSTALL_URL"
|
||||
}
|
||||
|
||||
# True only for a from-source git install (--repo): that path builds the web
|
||||
# UI with npm, so Node/npm are hard requirements. A default PyPI install pulls
|
||||
# the prebuilt wheel and needs neither.
|
||||
building_from_source() {
|
||||
[ -n "$INSTALL_URL" ]
|
||||
}
|
||||
|
||||
prompt_yes_no() {
|
||||
prompt="$1"
|
||||
|
||||
if [ "$NON_INTERACTIVE" = true ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! ( : </dev/tty ) 2>/dev/null; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s [Y/n] ' "$prompt" >/dev/tty
|
||||
if ! IFS= read -r answer </dev/tty; then
|
||||
answer=
|
||||
fi
|
||||
|
||||
case "$answer" in
|
||||
"" | y | Y | yes | YES | Yes)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
check_platform() {
|
||||
case "$(uname -s)" in
|
||||
Darwin | Linux)
|
||||
;;
|
||||
*)
|
||||
fail "install_oss.sh supports macOS and Linux only."
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Hard prerequisites. uv is always required (it performs the install). git is
|
||||
# only needed for a from-source `--repo` install — a PyPI wheel install never
|
||||
# touches git — so we check it only in that mode. The installer OFFERS to
|
||||
# install each with a trusted one-line installer rather than just failing.
|
||||
check_prerequisites() {
|
||||
if building_from_source; then
|
||||
ensure_git
|
||||
fi
|
||||
ensure_uv
|
||||
}
|
||||
|
||||
ensure_git() {
|
||||
if command -v git >/dev/null 2>&1; then
|
||||
step "git is available"
|
||||
return
|
||||
fi
|
||||
case "$(uname -s)" in
|
||||
Linux)
|
||||
install_cmd="$(linux_pkg_install_cmd git)"
|
||||
if [ -n "$install_cmd" ] && prompt_yes_no "git is required and not installed. Install it now ($install_cmd)?"; then
|
||||
# Run directly (not via run_with_spinner) so sudo can prompt for a password.
|
||||
sh -c "$install_cmd" || true
|
||||
command -v git >/dev/null 2>&1 && { step "git installed"; return; }
|
||||
fi
|
||||
;;
|
||||
Darwin)
|
||||
warn "git is required. Install the Xcode command-line tools with: xcode-select --install"
|
||||
;;
|
||||
esac
|
||||
fail "git is required. Install it, then rerun this installer."
|
||||
}
|
||||
|
||||
# uv performs the install, so it's required; offer the official one-liner,
|
||||
# and fall back to a fail-with-hint on decline/non-interactive/failure.
|
||||
ensure_uv() {
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
step "uv is available"
|
||||
return
|
||||
fi
|
||||
installer='curl -LsSf https://astral.sh/uv/install.sh | sh'
|
||||
if prompt_yes_no "uv is required and not installed. Install it now ($installer)?"; then
|
||||
run_with_spinner "install uv" sh -c "$installer" || true
|
||||
# uv's installer drops the binary in ~/.local/bin (or ~/.cargo/bin) and
|
||||
# wires PATH for *future* shells; pull it onto PATH for the rest of this run.
|
||||
if ! command -v uv >/dev/null 2>&1; then
|
||||
for d in "$HOME/.local/bin" "$HOME/.cargo/bin"; do
|
||||
if [ -x "$d/uv" ]; then
|
||||
PATH="$d:$PATH"
|
||||
export PATH
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
step "Installed uv"
|
||||
return
|
||||
fi
|
||||
fail "uv installed but is not on PATH — open a new shell and rerun this installer."
|
||||
fi
|
||||
fail "uv is required. Install from https://docs.astral.sh/uv/getting-started/installation/, then rerun."
|
||||
}
|
||||
|
||||
# ── Harness toolchain ────────────────────────────────────────────────
|
||||
#
|
||||
# Node 22+/npm power the Claude/Codex/Pi harnesses; the default PyPI install
|
||||
# and the bare web UI don't need them, so a missing one is a warning here.
|
||||
# With --repo (source build) npm is required up front, so it becomes an error.
|
||||
|
||||
# Report a missing prereq: fatal when building from source, otherwise a warning.
|
||||
require_or_warn() {
|
||||
if building_from_source; then
|
||||
fail "$1"
|
||||
fi
|
||||
warn "$1"
|
||||
}
|
||||
|
||||
# Node >= 22.10: the Claude/Codex/Pi CLIs need worker_threads.markAsUncloneable
|
||||
# (added in 22.10). Probe the symbol rather than parse a version string.
|
||||
check_node() {
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
require_or_warn "node not found — Node.js 22+ is needed for the Claude/Codex/Pi harnesses (https://nodejs.org)."
|
||||
return
|
||||
fi
|
||||
if node -e "process.exit(typeof require('node:worker_threads').markAsUncloneable === 'function' ? 0 : 1)" >/dev/null 2>&1; then
|
||||
step "Node.js is new enough for the harness CLIs"
|
||||
else
|
||||
require_or_warn "Node.js is older than 22.10 — the Claude/Codex/Pi harnesses need 22 LTS or newer (https://nodejs.org)."
|
||||
fi
|
||||
}
|
||||
|
||||
check_npm() {
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
step "npm is available (installs the Claude/Codex/Pi harness CLIs on first run)"
|
||||
else
|
||||
require_or_warn "npm not found — needed to install the Claude/Codex/Pi harness CLIs (https://nodejs.org)."
|
||||
fi
|
||||
}
|
||||
|
||||
# `omnigent claude` / `omnigent codex` launch through a local tmux terminal
|
||||
# and won't start without it, so surface it up front and offer to install it.
|
||||
# Emit the package-manager command that installs $1 on this Linux box, or
|
||||
# nothing when no known package manager is present. Shared by the tmux and
|
||||
# git install offers.
|
||||
linux_pkg_install_cmd() {
|
||||
pkg="$1"
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
printf 'sudo apt-get install -y %s' "$pkg"
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
printf 'sudo dnf install -y %s' "$pkg"
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
printf 'sudo yum install -y %s' "$pkg"
|
||||
elif command -v pacman >/dev/null 2>&1; then
|
||||
printf 'sudo pacman -S --noconfirm %s' "$pkg"
|
||||
elif command -v zypper >/dev/null 2>&1; then
|
||||
printf 'sudo zypper install -y %s' "$pkg"
|
||||
fi
|
||||
}
|
||||
|
||||
check_tmux() {
|
||||
if command -v tmux >/dev/null 2>&1; then
|
||||
step "tmux is available"
|
||||
return
|
||||
fi
|
||||
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
if command -v brew >/dev/null 2>&1; then
|
||||
if prompt_yes_no "tmux is missing (needed for \`omnigent claude\` / \`omnigent codex\`). Install it with brew?"; then
|
||||
run_with_spinner "brew install tmux" brew install tmux || warn "brew install tmux failed — install tmux manually before \`omnigent claude\`."
|
||||
return
|
||||
fi
|
||||
fi
|
||||
warn "tmux not found — \`omnigent claude\` / \`omnigent codex\` need it. Install with: brew install tmux"
|
||||
;;
|
||||
Linux)
|
||||
install_cmd="$(linux_pkg_install_cmd tmux)"
|
||||
if [ -n "$install_cmd" ] && prompt_yes_no "tmux is missing (needed for \`omnigent claude\` / \`omnigent codex\`). Install it now ($install_cmd)?"; then
|
||||
# Run directly (not via run_with_spinner) so sudo can prompt for a password.
|
||||
sh -c "$install_cmd" || warn "tmux install failed — run manually: $install_cmd"
|
||||
command -v tmux >/dev/null 2>&1 && step "tmux installed"
|
||||
return
|
||||
fi
|
||||
if [ -n "$install_cmd" ]; then
|
||||
warn "tmux not found — \`omnigent claude\` / \`omnigent codex\` need it. Install with: $install_cmd"
|
||||
else
|
||||
warn "tmux not found — \`omnigent claude\` / \`omnigent codex\` need it. Install it with your package manager."
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# The native `omnigent claude` / `omnigent codex` / `pi` harnesses wrap each
|
||||
# agent terminal in a bubblewrap (`bwrap`) OS-sandbox; on Linux that isolation
|
||||
# is mandatory and fail-loud, so a missing `bwrap` binary makes those terminals
|
||||
# fail to start. macOS sandboxes with the built-in seatbelt backend and needs
|
||||
# nothing here, so this check is Linux-only.
|
||||
check_bubblewrap() {
|
||||
[ "$(uname -s)" = Linux ] || return 0
|
||||
|
||||
if command -v bwrap >/dev/null 2>&1; then
|
||||
step "bubblewrap (bwrap) is available"
|
||||
return
|
||||
fi
|
||||
|
||||
install_cmd="$(linux_pkg_install_cmd bubblewrap)"
|
||||
if [ -n "$install_cmd" ] && prompt_yes_no "bubblewrap is missing (needed to sandbox native \`omnigent claude\` / \`omnigent codex\` terminals). Install it now ($install_cmd)?"; then
|
||||
run_with_spinner "install bubblewrap" sh -c "$install_cmd" || warn "bubblewrap install failed — run manually: $install_cmd"
|
||||
return
|
||||
fi
|
||||
if [ -n "$install_cmd" ]; then
|
||||
warn "bubblewrap (bwrap) not found — native \`omnigent claude\` / \`omnigent codex\` terminals need it on Linux. Install with: $install_cmd"
|
||||
else
|
||||
warn "bubblewrap (bwrap) not found — native \`omnigent claude\` / \`omnigent codex\` terminals need it on Linux. Install it with your package manager."
|
||||
fi
|
||||
}
|
||||
|
||||
install_omnigent() {
|
||||
# Default: the published PyPI wheel (`omnigent`, optionally `omnigent==X`).
|
||||
# The wheel ships the prebuilt web UI, so there is no npm/Node step and no
|
||||
# source build — the fast, reliable path. `--repo` switches INSTALL_URL to a
|
||||
# git ref, which builds from source (and needs npm, checked above).
|
||||
# Extras suffix like "[databricks]" appended to the package name so the
|
||||
# optional-dependency group(s) install alongside the base package. Applies to
|
||||
# every mode below; empty when no --extra was given.
|
||||
extras_suffix=
|
||||
if [ -n "$EXTRAS" ]; then
|
||||
extras_suffix="[$EXTRAS]"
|
||||
fi
|
||||
if building_from_source; then
|
||||
# A PEP 508 direct reference attaches extras to a git source install:
|
||||
# "omnigent[databricks] @ git+https://...". Without extras, keep the bare
|
||||
# URL (the long-standing form uv accepts directly).
|
||||
if [ -n "$extras_suffix" ]; then
|
||||
target="${PACKAGE_NAME}${extras_suffix} @ ${INSTALL_URL}"
|
||||
else
|
||||
target="$INSTALL_URL"
|
||||
fi
|
||||
step "Installing Omnigent from source${extras_suffix:+ $extras_suffix} (Python $PYTHON_VERSION)"
|
||||
elif [ -n "$VERSION" ]; then
|
||||
target="${PACKAGE_NAME}${extras_suffix}==${VERSION}"
|
||||
step "Installing Omnigent $VERSION${extras_suffix:+ $extras_suffix} (Python $PYTHON_VERSION)"
|
||||
else
|
||||
target="${PACKAGE_NAME}${extras_suffix}"
|
||||
step "Installing Omnigent${extras_suffix:+ $extras_suffix} (Python $PYTHON_VERSION)"
|
||||
fi
|
||||
# --force so re-running upgrades instead of no-op'ing; -q hides uv's
|
||||
# "Installed N executables" summary (the package also ships an `omni` alias).
|
||||
run_with_spinner "uv tool install" uv tool install --force -q --python "$PYTHON_VERSION" "$target"
|
||||
}
|
||||
|
||||
uv_tool_bin_dir() {
|
||||
uv tool dir --bin
|
||||
}
|
||||
|
||||
path_contains() {
|
||||
dir="$1"
|
||||
|
||||
case ":$PATH:" in
|
||||
*":$dir:"*)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
pick_profile() {
|
||||
case "$(uname -s):${SHELL:-}" in
|
||||
Darwin:*/zsh)
|
||||
printf '%s/.zprofile\n' "$HOME"
|
||||
;;
|
||||
Darwin:*/bash)
|
||||
printf '%s/.bash_profile\n' "$HOME"
|
||||
;;
|
||||
Linux:*/zsh)
|
||||
printf '%s/.zshrc\n' "$HOME"
|
||||
;;
|
||||
Linux:*/bash)
|
||||
printf '%s/.bashrc\n' "$HOME"
|
||||
;;
|
||||
*)
|
||||
printf '%s/.profile\n' "$HOME"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
maybe_add_bin_to_path() {
|
||||
bin_dir="$1"
|
||||
|
||||
if path_contains "$bin_dir"; then
|
||||
step "$bin_dir is already on PATH"
|
||||
return
|
||||
fi
|
||||
|
||||
path_line="export PATH=\"$bin_dir:\$PATH\""
|
||||
profile="$(pick_profile)"
|
||||
begin_marker="# >>> Omnigent installer >>>"
|
||||
end_marker="# <<< Omnigent installer <<<"
|
||||
|
||||
warn "$bin_dir is not on PATH."
|
||||
if [ "$NON_INTERACTIVE" = true ]; then
|
||||
warn "Add it with: $path_line"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -f "$profile" ] && grep -F "$begin_marker" "$profile" >/dev/null 2>&1; then
|
||||
if grep -F "$path_line" "$profile" >/dev/null 2>&1; then
|
||||
step "PATH is already configured in $profile"
|
||||
return
|
||||
fi
|
||||
fail "$profile already has an Omnigent installer block. Update it manually to: $path_line"
|
||||
fi
|
||||
|
||||
if ! prompt_yes_no "Add $bin_dir to PATH in $profile?"; then
|
||||
warn "Skipping PATH update. Current shell can run: $path_line"
|
||||
return
|
||||
fi
|
||||
|
||||
mkdir -p "${profile%/*}"
|
||||
{
|
||||
printf '\n%s\n' "$begin_marker"
|
||||
printf '%s\n' "$path_line"
|
||||
printf '%s\n' "$end_marker"
|
||||
} >>"$profile"
|
||||
step "Added $bin_dir to PATH in $profile"
|
||||
}
|
||||
|
||||
verify_omnigent() {
|
||||
bin_dir="$1"
|
||||
cli_path="$bin_dir/omnigent"
|
||||
|
||||
if [ ! -x "$cli_path" ]; then
|
||||
cli_path="$(command -v omnigent 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [ -z "$cli_path" ]; then
|
||||
fail "Omnigent installed, but the omnigent command was not found."
|
||||
fi
|
||||
|
||||
"$cli_path" --help >/dev/null
|
||||
step "Verified $cli_path"
|
||||
|
||||
# `omni` is a shorthand alias installed alongside `omnigent`; check it so a
|
||||
# packaging regression that drops it surfaces here rather than later.
|
||||
for alias_cmd in omni; do
|
||||
if [ ! -x "$bin_dir/$alias_cmd" ] && ! command -v "$alias_cmd" >/dev/null 2>&1; then
|
||||
warn "the $alias_cmd alias was not installed (expected a console-script entry point alongside omnigent)."
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# No setup step here by design: the first `omnigent` run configures a model
|
||||
# credential and offers to install the harness CLI you pick.
|
||||
print_next_steps() {
|
||||
bin_dir="$1"
|
||||
command_prefix=
|
||||
|
||||
if ! path_contains "$bin_dir"; then
|
||||
command_prefix="PATH=\"$bin_dir:\$PATH\" "
|
||||
fi
|
||||
|
||||
printf '\n%sOmnigent installed successfully.%s\n\n' "$BOLD" "$RESET"
|
||||
printf 'Start chatting — first run sets up a model and a local web UI:\n'
|
||||
printf ' %s%somnigent%s\n\n' "$command_prefix" "$MAGENTA" "$RESET"
|
||||
printf 'Or launch a specific coding harness:\n'
|
||||
printf ' %somnigent claude # Claude Code\n' "$command_prefix"
|
||||
printf ' %somnigent codex # Codex\n\n' "$command_prefix"
|
||||
printf 'Manage model credentials any time:\n'
|
||||
printf ' %somnigent setup\n\n' "$command_prefix"
|
||||
printf '%sUsing a Databricks workspace as your model provider? Install the\n' "$DIM"
|
||||
printf 'Databricks CLI (https://docs.databricks.com/aws/en/dev-tools/cli/install)\n'
|
||||
printf 'and add it via: omnigent setup -> Databricks.%s\n' "$RESET"
|
||||
}
|
||||
|
||||
main() {
|
||||
init_style
|
||||
parse_args "$@"
|
||||
print_banner
|
||||
normalize_repo_url
|
||||
check_platform
|
||||
check_prerequisites
|
||||
check_node
|
||||
check_npm
|
||||
check_tmux
|
||||
check_bubblewrap
|
||||
install_omnigent
|
||||
bin_dir="$(uv_tool_bin_dir)"
|
||||
verify_omnigent "$bin_dir"
|
||||
maybe_add_bin_to_path "$bin_dir"
|
||||
print_next_steps "$bin_dir"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Normalize the package index in ``uv.lock`` to the public PyPI URL.
|
||||
|
||||
Local ``uv`` runs resolve against whatever index is configured on the
|
||||
developer's machine (e.g. the Databricks PyPI proxy via ``UV_INDEX_URL``
|
||||
or ``~/.config/uv``), and ``uv`` rewrites every
|
||||
``source = { registry = "<url>" }`` entry in ``uv.lock`` to that index.
|
||||
For this OSS repo the committed lockfile must always point at public
|
||||
PyPI (``https://pypi.org/simple``) so the lock is reproducible for
|
||||
contributors who don't have the proxy — CI already pins
|
||||
``UV_INDEX_URL: https://pypi.org/simple`` for the same reason.
|
||||
|
||||
This is a pre-commit *fixer*: it rewrites the registry URL in place and
|
||||
exits non-zero when it changed anything, so the commit aborts and the
|
||||
developer re-stages the normalized lockfile (mirroring
|
||||
``end-of-file-fixer`` and friends). Only ``registry`` sources are
|
||||
touched; ``git`` / ``path`` / ``editable`` sources are left alone.
|
||||
|
||||
Pass ``--check`` to validate without writing: it exits non-zero (and
|
||||
names the offending URLs) when a file is *not* already canonical, but
|
||||
leaves it untouched. CI runs this mode against the committed lockfile
|
||||
*before* any ``uv`` command — a plain ``uv run pre-commit`` can't catch a
|
||||
committed proxy URL, because ``uv`` re-syncs the working tree to CI's
|
||||
own index (``pypi.org``) first and masks it.
|
||||
|
||||
Usage::
|
||||
|
||||
python scripts/normalize_uv_lock_registry.py uv.lock # fix
|
||||
python scripts/normalize_uv_lock_registry.py --check uv.lock # verify
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# The canonical public index the committed lockfile must always use.
|
||||
_CANONICAL_INDEX = "https://pypi.org/simple"
|
||||
|
||||
# The canonical file host for wheel/sdist direct URLs.
|
||||
_CANONICAL_FILES_HOST = "https://files.pythonhosted.org"
|
||||
|
||||
# Matches a uv.lock registry source, capturing the surrounding literal so
|
||||
# only the URL between the quotes is replaced, e.g.
|
||||
# source = { registry = "https://pypi-proxy.example.com/simple" }
|
||||
_REGISTRY_RE = re.compile(r'(registry = ")[^"]*(")')
|
||||
|
||||
# Matches any non-pypi.org host in a direct wheel/sdist url = "..." entry so
|
||||
# proxy-resolved URLs (e.g. pypi-proxy.cloud.databricks.com) can be rewritten
|
||||
# to files.pythonhosted.org. The path component (/packages/…) is identical
|
||||
# between the proxy and the canonical host.
|
||||
_DIRECT_URL_RE = re.compile(
|
||||
r'(url = ")(https?://(?!files\.pythonhosted\.org)[^"]+?/packages/)([^"]*")'
|
||||
)
|
||||
|
||||
|
||||
def non_canonical_registries(text: str) -> list[str]:
|
||||
"""Return the registry URLs and direct-URL hosts in *text* that are not canonical.
|
||||
|
||||
:param text: Full contents of a ``uv.lock`` file.
|
||||
:returns: Each non-canonical URL, in order, with duplicates preserved.
|
||||
"""
|
||||
bad: list[str] = [
|
||||
m.group(1)
|
||||
for m in re.finditer(r'registry = "([^"]*)"', text)
|
||||
if m.group(1) != _CANONICAL_INDEX
|
||||
]
|
||||
bad += [m.group(2) for m in _DIRECT_URL_RE.finditer(text)]
|
||||
return bad
|
||||
|
||||
|
||||
def normalize_text(text: str) -> str:
|
||||
"""Return *text* with every registry and direct wheel/sdist URL rewritten to canonical hosts.
|
||||
|
||||
:param text: Full contents of a ``uv.lock`` file.
|
||||
:returns: The normalized text.
|
||||
"""
|
||||
return _DIRECT_URL_RE.sub(
|
||||
rf"\g<1>{_CANONICAL_FILES_HOST}/packages/\g<3>",
|
||||
_REGISTRY_RE.sub(rf"\g<1>{_CANONICAL_INDEX}\g<2>", text),
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
"""Normalize (or, with ``--check``, validate) each given lockfile.
|
||||
|
||||
:param argv: Filenames to process, optionally preceded/followed by the
|
||||
``--check`` flag (passed by pre-commit or CI).
|
||||
:returns: In fix mode, ``1`` when a file was modified (so the commit
|
||||
aborts and the change is re-staged) else ``0``. In ``--check``
|
||||
mode, ``1`` when any file is not already canonical (printing the
|
||||
offending URLs) else ``0``; no file is written.
|
||||
"""
|
||||
check = "--check" in argv
|
||||
files = [a for a in argv if a != "--check"]
|
||||
|
||||
if check:
|
||||
ok = True
|
||||
for name in files:
|
||||
offenders = non_canonical_registries(Path(name).read_text())
|
||||
if offenders:
|
||||
ok = False
|
||||
unique = sorted(set(offenders))
|
||||
print(
|
||||
f"{name}: {len(offenders)} non-canonical registry "
|
||||
f"source(s) (expected {_CANONICAL_INDEX}): {', '.join(unique)}"
|
||||
)
|
||||
print(
|
||||
"Fix with: python scripts/normalize_uv_lock_registry.py "
|
||||
f"{name} && git add {name}"
|
||||
)
|
||||
return 0 if ok else 1
|
||||
|
||||
changed = False
|
||||
for name in files:
|
||||
path = Path(name)
|
||||
original = path.read_text()
|
||||
normalized = normalize_text(original)
|
||||
if normalized != original:
|
||||
path.write_text(normalized)
|
||||
print(f"{name}: normalized package index to {_CANONICAL_INDEX}")
|
||||
changed = True
|
||||
return 1 if changed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Sync ``omnigent/version.py``'s ``VERSION`` to the canonical pyproject version.
|
||||
|
||||
The root ``pyproject.toml``'s ``[project].version`` is the single source of
|
||||
truth for the release version (stamped in lockstep with the SDK packages by
|
||||
``scripts/update_versions.py``). The runtime, however, reads
|
||||
``omnigent.version.VERSION`` — a plain constant it can import without touching
|
||||
package metadata. This script keeps that constant equal to the canonical
|
||||
pyproject version so the two never drift.
|
||||
|
||||
It is a pre-commit *fixer*: it rewrites the ``VERSION`` literal in place and
|
||||
exits non-zero when it changed anything, so the commit aborts and the developer
|
||||
re-stages the synced file (mirroring ``end-of-file-fixer`` and
|
||||
``normalize_uv_lock_registry``).
|
||||
|
||||
Pass ``--check`` to validate without writing: it exits non-zero (and prints the
|
||||
mismatch) when the constant is stale, but leaves the file untouched. (CI-side
|
||||
drift is caught by ``scripts/update_versions.py check`` and the
|
||||
``test_version_matches_pyproject`` test; this flag is for ad-hoc local use.)
|
||||
|
||||
Usage::
|
||||
|
||||
python scripts/sync_version_py.py # fix
|
||||
python scripts/sync_version_py.py --check # verify
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import tomllib
|
||||
|
||||
# scripts/sync_version_py.py -> repo root is one level up.
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
_PYPROJECT = _REPO_ROOT / "pyproject.toml"
|
||||
_VERSION_PY = _REPO_ROOT / "omnigent" / "version.py"
|
||||
|
||||
# The ``VERSION = "..."`` assignment (its own line) in omnigent/version.py.
|
||||
_VERSION_ASSIGN = re.compile(r'^VERSION = "[^"]*"$', re.MULTILINE)
|
||||
|
||||
|
||||
def _canonical_version() -> str:
|
||||
"""Return ``[project].version`` from the root ``pyproject.toml``."""
|
||||
return tomllib.loads(_PYPROJECT.read_text(encoding="utf-8"))["project"]["version"]
|
||||
|
||||
|
||||
def _current_constant(text: str) -> str:
|
||||
"""Return the ``VERSION`` literal currently in *text*.
|
||||
|
||||
:param text: Contents of ``omnigent/version.py``.
|
||||
:returns: The quoted value of the ``VERSION`` assignment.
|
||||
:raises ValueError: If the assignment is missing or not unique.
|
||||
"""
|
||||
matches = _VERSION_ASSIGN.findall(text)
|
||||
if len(matches) != 1:
|
||||
raise ValueError(
|
||||
f'expected exactly one `VERSION = "..."` line in {_VERSION_PY}, found {len(matches)}'
|
||||
)
|
||||
return matches[0].split('"')[1]
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Sync (or, with ``--check``, verify) the ``VERSION`` constant.
|
||||
|
||||
:param argv: Argument list (defaults to ``sys.argv[1:]``).
|
||||
:returns: Process exit code — ``0`` when already in sync, ``1`` when a
|
||||
rewrite was needed (fix mode) or a drift was found (check mode).
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="verify without writing; exit non-zero on drift",
|
||||
)
|
||||
# pre-commit passes the matched filenames; we operate on fixed paths, so
|
||||
# accept and ignore them.
|
||||
parser.add_argument("files", nargs="*", help=argparse.SUPPRESS)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
canonical = _canonical_version()
|
||||
text = _VERSION_PY.read_text(encoding="utf-8")
|
||||
current = _current_constant(text)
|
||||
|
||||
if current == canonical:
|
||||
return 0
|
||||
|
||||
if args.check:
|
||||
print(
|
||||
f"{_VERSION_PY.name}: VERSION is {current!r} but pyproject.toml is "
|
||||
f"{canonical!r}; run `python scripts/sync_version_py.py` to fix",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
new_text = _VERSION_ASSIGN.sub(f'VERSION = "{canonical}"', text)
|
||||
_VERSION_PY.write_text(new_text, encoding="utf-8")
|
||||
print(
|
||||
f"{_VERSION_PY.name}: synced VERSION {current!r} -> {canonical!r} (re-stage the file)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
Bump the omnigent project version across all packages in lockstep.
|
||||
|
||||
The three distributions in this repo release together at a single
|
||||
version:
|
||||
|
||||
- ``omnigent`` — root ``pyproject.toml``
|
||||
- ``omnigent-client`` — ``sdks/python-client/pyproject.toml``
|
||||
- ``omnigent-ui-sdk`` — ``sdks/ui/pyproject.toml``
|
||||
|
||||
Each declares its own ``[project].version`` and ``==``-pins its
|
||||
siblings — the lockstep contract that
|
||||
``.github/workflows/release-omnigent.yml`` verifies at tag time. This
|
||||
script rewrites every one of those locations at once so they never
|
||||
drift.
|
||||
|
||||
It edits ONLY the ``[project].version`` line and the sibling ``==``
|
||||
pins, matched by package name — never a blind version-string replace —
|
||||
so unrelated version literals (host/runner wire-protocol versions,
|
||||
docstring examples, third-party dependency floors like
|
||||
``databricks-mcp>=0.1.0``) are left untouched.
|
||||
|
||||
``web/package.json`` (a ``0.0.0`` sentinel for the private SPA) and
|
||||
``web/electron/package.json`` (the desktop app's independent
|
||||
version) are intentionally OUT of scope: neither is part of the
|
||||
release-validated Python lockstep.
|
||||
|
||||
After editing the ``pyproject.toml`` files, regenerate the lockfile so
|
||||
the embedded sibling specifiers track the new version::
|
||||
|
||||
uv lock
|
||||
|
||||
Usage::
|
||||
|
||||
# Stamp an exact version (cutting a release or release candidate):
|
||||
python scripts/update_versions.py pre-release --new-version 0.1.2
|
||||
python scripts/update_versions.py pre-release --new-version 0.1.2rc1
|
||||
|
||||
# After releasing X, move main to the next dev version:
|
||||
python scripts/update_versions.py post-release --new-version 0.1.2
|
||||
# -> stamps 0.1.3.dev0 everywhere
|
||||
|
||||
# Verify every location agrees (prints the resolved version):
|
||||
python scripts/update_versions.py check
|
||||
python scripts/update_versions.py check --expect 0.1.2
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import tomllib
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
# scripts/update_versions.py -> repo root is one level up.
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Package:
|
||||
"""
|
||||
One lockstep-versioned distribution in the repo.
|
||||
|
||||
:param name: Distribution name, e.g. ``"omnigent"``.
|
||||
:param pyproject: Path to the package's ``pyproject.toml``, e.g.
|
||||
``Path("sdks/python-client/pyproject.toml")``.
|
||||
:param sibling_pins: Sibling distribution names this package
|
||||
``==``-pins, e.g. ``("omnigent-client", "omnigent-ui-sdk")``.
|
||||
Empty for a package that pins no siblings.
|
||||
"""
|
||||
|
||||
name: str
|
||||
pyproject: Path
|
||||
sibling_pins: tuple[str, ...]
|
||||
|
||||
|
||||
def packages(root: Path) -> list[Package]:
|
||||
"""
|
||||
Return the lockstep packages with their paths rooted at *root*.
|
||||
|
||||
:param root: Repo root, e.g. ``Path("/repo")``.
|
||||
:returns: The three :class:`Package` entries.
|
||||
"""
|
||||
return [
|
||||
Package(
|
||||
"omnigent",
|
||||
root / "pyproject.toml",
|
||||
("omnigent-client", "omnigent-ui-sdk"),
|
||||
),
|
||||
Package(
|
||||
"omnigent-client",
|
||||
root / "sdks" / "python-client" / "pyproject.toml",
|
||||
("omnigent",),
|
||||
),
|
||||
Package(
|
||||
"omnigent-ui-sdk",
|
||||
root / "sdks" / "ui" / "pyproject.toml",
|
||||
("omnigent-client",),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# ``version = "..."`` on its own line (the [project].version field).
|
||||
_VERSION_LINE = re.compile(r'^version = "[^"]*"$', re.MULTILINE)
|
||||
|
||||
# ``VERSION = "..."`` on its own line — the runtime constant in
|
||||
# ``omnigent/version.py`` that mirrors the canonical [project].version.
|
||||
_VERSION_CONSTANT = re.compile(r'^VERSION = "[^"]*"$', re.MULTILINE)
|
||||
|
||||
|
||||
def _version_py(root: Path) -> Path:
|
||||
"""Return the path to the runtime version constant module."""
|
||||
return root / "omnigent" / "version.py"
|
||||
|
||||
|
||||
def _pin_pattern(name: str) -> re.Pattern[str]:
|
||||
"""
|
||||
Build the regex matching a quoted ``"<name>==<ver>",`` dependency.
|
||||
|
||||
Anchored on the exact distribution *name* so a blind version
|
||||
literal is never matched, and capturing the leading indent so it
|
||||
is preserved on rewrite.
|
||||
|
||||
:param name: Distribution name to match, e.g. ``"omnigent-client"``.
|
||||
:returns: A compiled multiline pattern.
|
||||
"""
|
||||
return re.compile(rf'^(?P<indent>\s*)"{re.escape(name)}==[^"]*",$', re.MULTILINE)
|
||||
|
||||
|
||||
def _sub_exactly_once(pattern: re.Pattern[str], repl: str, text: str, where: str) -> str:
|
||||
"""
|
||||
Substitute *pattern* with *repl* in *text*, requiring one match.
|
||||
|
||||
Failing loud on zero or multiple matches turns a drifted file
|
||||
format (renamed field, duplicated pin) into an immediate error
|
||||
rather than a silent partial edit.
|
||||
|
||||
:param pattern: Compiled pattern to replace.
|
||||
:param repl: Replacement string (may reference groups).
|
||||
:param text: Source text.
|
||||
:param where: Human description for the error, e.g.
|
||||
``"[project].version in pyproject.toml"``.
|
||||
:returns: The edited text.
|
||||
:raises ValueError: If the match count is not exactly one.
|
||||
"""
|
||||
new_text, count = pattern.subn(repl, text)
|
||||
if count != 1:
|
||||
raise ValueError(f"expected exactly 1 match for {where}, found {count}")
|
||||
return new_text
|
||||
|
||||
|
||||
def read_version(root: Path) -> str:
|
||||
"""
|
||||
Read the canonical version from the root ``pyproject.toml``.
|
||||
|
||||
:param root: Repo root.
|
||||
:returns: The version string, e.g. ``"0.1.2.dev0"``.
|
||||
"""
|
||||
data = tomllib.loads((root / "pyproject.toml").read_text())
|
||||
return data["project"]["version"]
|
||||
|
||||
|
||||
def set_version(root: Path, new_version: str) -> list[Path]:
|
||||
"""
|
||||
Rewrite every package's version + sibling pins to *new_version*.
|
||||
|
||||
Also rewrites the runtime ``VERSION`` constant in ``omnigent/version.py``
|
||||
so the value the runtime imports stays equal to ``[project].version`` —
|
||||
the automated bump path must keep both in lockstep (the ``sync-version-py``
|
||||
pre-commit fixer only fires in the local dev flow).
|
||||
|
||||
:param root: Repo root.
|
||||
:param new_version: PEP 440 version to stamp, e.g. ``"0.1.2"``.
|
||||
:returns: The list of files changed (in edit order).
|
||||
:raises ValueError: If any expected line is missing or duplicated.
|
||||
"""
|
||||
changed: list[Path] = []
|
||||
for pkg in packages(root):
|
||||
text = pkg.pyproject.read_text()
|
||||
text = _sub_exactly_once(
|
||||
_VERSION_LINE,
|
||||
f'version = "{new_version}"',
|
||||
text,
|
||||
f"[project].version in {pkg.pyproject}",
|
||||
)
|
||||
for sibling in pkg.sibling_pins:
|
||||
text = _sub_exactly_once(
|
||||
_pin_pattern(sibling),
|
||||
rf'\g<indent>"{sibling}=={new_version}",',
|
||||
text,
|
||||
f"{sibling}== pin in {pkg.pyproject}",
|
||||
)
|
||||
pkg.pyproject.write_text(text)
|
||||
changed.append(pkg.pyproject)
|
||||
|
||||
version_py = _version_py(root)
|
||||
version_text = _sub_exactly_once(
|
||||
_VERSION_CONSTANT,
|
||||
f'VERSION = "{new_version}"',
|
||||
version_py.read_text(),
|
||||
f"VERSION constant in {version_py}",
|
||||
)
|
||||
version_py.write_text(version_text)
|
||||
changed.append(version_py)
|
||||
|
||||
return changed
|
||||
|
||||
|
||||
def next_dev_version(released: str) -> str:
|
||||
"""
|
||||
Compute the next development version after releasing *released*.
|
||||
|
||||
Mirrors MLflow's post-release convention: bump the patch component
|
||||
and append ``.dev0`` (e.g. ``0.1.2`` -> ``0.1.3.dev0``).
|
||||
|
||||
:param released: The just-released version, e.g. ``"0.1.2"``.
|
||||
:returns: The next dev version, e.g. ``"0.1.3.dev0"``.
|
||||
"""
|
||||
v = Version(released)
|
||||
return f"{v.major}.{v.minor}.{v.micro + 1}.dev0"
|
||||
|
||||
|
||||
def _read_version_constant(root: Path) -> str:
|
||||
"""
|
||||
Return the ``VERSION`` literal from ``omnigent/version.py``.
|
||||
|
||||
:param root: Repo root.
|
||||
:returns: The quoted value of the ``VERSION`` assignment.
|
||||
:raises ValueError: If the assignment is missing or not unique.
|
||||
"""
|
||||
version_py = _version_py(root)
|
||||
matches = _VERSION_CONSTANT.findall(version_py.read_text())
|
||||
if len(matches) != 1:
|
||||
raise ValueError(
|
||||
f'expected exactly one `VERSION = "..."` line in {version_py}, found {len(matches)}'
|
||||
)
|
||||
return matches[0].split('"')[1]
|
||||
|
||||
|
||||
def check(root: Path, expect: str | None = None) -> str:
|
||||
"""
|
||||
Verify every package agrees on the version and pins its siblings.
|
||||
|
||||
Also checks the runtime ``VERSION`` constant in ``omnigent/version.py``
|
||||
against the resolved version, so a bump that forgets it fails here rather
|
||||
than in the ``test_version_matches_pyproject`` backstop on the bot PR.
|
||||
|
||||
:param root: Repo root.
|
||||
:param expect: If given, additionally assert the resolved version
|
||||
equals this (compared as PEP 440), e.g. ``"0.1.2"``.
|
||||
:returns: The single resolved version string.
|
||||
:raises ValueError: If versions disagree, a sibling pin is missing
|
||||
or not pinned to the package's own version, the runtime ``VERSION``
|
||||
constant differs, or the resolved version differs from *expect*.
|
||||
"""
|
||||
versions: dict[str, str] = {}
|
||||
for pkg in packages(root):
|
||||
project = tomllib.loads(pkg.pyproject.read_text())["project"]
|
||||
versions[pkg.name] = project["version"]
|
||||
deps = project.get("dependencies", [])
|
||||
for sibling in pkg.sibling_pins:
|
||||
pin = f"{sibling}=={project['version']}"
|
||||
if pin not in deps:
|
||||
raise ValueError(f"{pkg.pyproject}: missing exact pin {pin!r}")
|
||||
unique = set(versions.values())
|
||||
if len(unique) != 1:
|
||||
raise ValueError(f"package versions disagree: {versions}")
|
||||
resolved = unique.pop()
|
||||
constant = _read_version_constant(root)
|
||||
if Version(constant) != Version(resolved):
|
||||
raise ValueError(
|
||||
f"omnigent/version.py VERSION {constant!r} != [project].version {resolved!r}"
|
||||
)
|
||||
if expect is not None and Version(resolved) != Version(expect):
|
||||
raise ValueError(f"resolved version {resolved} != expected {expect}")
|
||||
return resolved
|
||||
|
||||
|
||||
def _validate_pep440(value: str) -> str:
|
||||
"""
|
||||
Validate *value* is a PEP 440 version, exiting loudly otherwise.
|
||||
|
||||
:param value: Candidate version string, e.g. ``"0.1.2rc1"``.
|
||||
:returns: The same value.
|
||||
"""
|
||||
try:
|
||||
Version(value)
|
||||
except InvalidVersion as exc:
|
||||
raise SystemExit(f"invalid version {value!r}: {exc}") from exc
|
||||
return value
|
||||
|
||||
|
||||
def _cmd_pre_release(root: Path, new_version: str) -> None:
|
||||
"""Stamp *new_version* exactly across all packages."""
|
||||
_validate_pep440(new_version)
|
||||
changed = set_version(root, new_version)
|
||||
check(root, expect=new_version)
|
||||
print(f"Set version to {new_version} in:", file=sys.stderr)
|
||||
for path in changed:
|
||||
print(f" {path.relative_to(root)}", file=sys.stderr)
|
||||
print("Now run `uv lock` to update the lockfile.", file=sys.stderr)
|
||||
|
||||
|
||||
def _cmd_post_release(root: Path, released: str) -> None:
|
||||
"""Stamp the next ``.dev0`` after releasing *released*."""
|
||||
_validate_pep440(released)
|
||||
current = Version(read_version(root))
|
||||
if not current.is_devrelease:
|
||||
raise SystemExit(
|
||||
f"current version {current} is not a dev release; post-release must run "
|
||||
"on main (which carries a .devN version), not a release branch"
|
||||
)
|
||||
new_version = next_dev_version(released)
|
||||
set_version(root, new_version)
|
||||
check(root, expect=new_version)
|
||||
print(f"Bumped main to {new_version} (after release {released}).", file=sys.stderr)
|
||||
print("Now run `uv lock` to update the lockfile.", file=sys.stderr)
|
||||
|
||||
|
||||
def _cmd_check(root: Path, expect: str | None) -> None:
|
||||
"""Verify consistency and print the resolved version to stdout."""
|
||||
print(check(root, expect=expect))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
"""
|
||||
Parse args and dispatch to the requested subcommand.
|
||||
|
||||
:param argv: Argument list (defaults to ``sys.argv[1:]``).
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description="Bump omnigent package versions in lockstep")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
pre = sub.add_parser("pre-release", help="Stamp an exact version across all packages")
|
||||
pre.add_argument("--new-version", required=True, help="Version to stamp, e.g. 0.1.2")
|
||||
|
||||
post = sub.add_parser("post-release", help="Stamp the next .dev0 after a release")
|
||||
post.add_argument("--new-version", required=True, help="The just-released version, e.g. 0.1.2")
|
||||
|
||||
chk = sub.add_parser("check", help="Verify all packages agree (prints the version)")
|
||||
chk.add_argument("--expect", default=None, help="Assert the resolved version equals this")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
if args.command == "pre-release":
|
||||
_cmd_pre_release(_REPO_ROOT, args.new_version)
|
||||
elif args.command == "post-release":
|
||||
_cmd_post_release(_REPO_ROOT, args.new_version)
|
||||
elif args.command == "check":
|
||||
_cmd_check(_REPO_ROOT, args.expect)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user