chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:12:00 +08:00
commit 3de48288cb
2986 changed files with 1131193 additions and 0 deletions
+130
View File
@@ -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"
+849
View File
@@ -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())
+183
View File
@@ -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())
+642
View File
@@ -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 "$@"
+127
View File
@@ -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:]))
+107
View File
@@ -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())
+357
View File
@@ -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()