from __future__ import annotations import argparse import json import uuid from importlib.resources import files from pathlib import Path from typing import Sequence from hub_core.mcp import HubCoreMCPServer from hub_core.runtime.config import RuntimeSettings def build_parser(settings: RuntimeSettings | None = None) -> argparse.ArgumentParser: resolved = settings or RuntimeSettings.from_env() parser = argparse.ArgumentParser(prog="hub-core", description="Hub Core runtime commands") commands = parser.add_subparsers(dest="command", required=True) api = commands.add_parser("api", help="Run the HTTP API and named ports") api.add_argument("--host", default=resolved.api_host) api.add_argument("--port", type=int, default=resolved.api_port) mcp = commands.add_parser("mcp", help="Run the Hub Core MCP process") mcp.add_argument("--host", default=resolved.mcp_host) mcp.add_argument("--port", type=int, default=resolved.mcp_port) mcp.add_argument("--transport", default=resolved.mcp_transport) mcp.add_argument("--api-base", default=resolved.api_base) migrate = commands.add_parser("migrate", help="Run packaged Alembic migrations") migrate.add_argument("revision", nargs="?", default="head") migrate.add_argument("--database-url", default=resolved.database_url) migration = commands.add_parser("migration", help="Export, validate, or import migration bundles") migration_commands = migration.add_subparsers(dest="migration_command", required=True) migration_validate = migration_commands.add_parser("validate") migration_validate.add_argument("bundle", type=Path) migration_validate.add_argument("--output", type=Path) migration_import = migration_commands.add_parser("import") migration_import.add_argument("bundle", type=Path) migration_import.add_argument("--database-url", default=resolved.database_url) migration_import.add_argument("--dry-run", action="store_true") migration_import.add_argument("--output", type=Path) migration_export = migration_commands.add_parser("export") migration_export.add_argument("--database-url", default=resolved.database_url) migration_export.add_argument("--source-revision") migration_export.add_argument("--output", type=Path, required=True) message_alias = commands.add_parser( "message-alias", help="Manage explicit historical message identity aliases", ) message_alias_commands = message_alias.add_subparsers( dest="message_alias_command", required=True, ) message_alias_register = message_alias_commands.add_parser( "register", help="Bind one exact historical reference to an existing canonical message", ) message_alias_register.add_argument("--alias", required=True) message_alias_register.add_argument("--message-id", type=uuid.UUID, required=True) message_alias_register.add_argument("--source", required=True) message_alias_register.add_argument("--reason", required=True) message_alias_register.add_argument("--created-by", required=True) message_alias_register.add_argument("--database-url", default=resolved.database_url) message_alias_register.add_argument( "--confirm", action="store_true", help="Confirm the attributable database write", ) conformance = commands.add_parser( "conformance", help="Run the implemented Tier 2/3 profile against an HTTP runtime", ) conformance.add_argument("--base-url", default=resolved.api_base) conformance.add_argument("--timeout", type=float, default=10.0) conformance.add_argument("--json", action="store_true", dest="as_json") return parser def main(argv: Sequence[str] | None = None) -> int: settings = RuntimeSettings.from_env() args = build_parser(settings).parse_args(argv) if args.command == "api": _run_api(args.host, args.port) return 0 if args.command == "mcp": _run_mcp(args.host, args.port, args.transport, args.api_base) return 0 if args.command == "migrate": if not args.database_url: raise SystemExit("hub-core migrate requires --database-url or HUB_CORE_DATABASE_URL") _run_migrations(args.database_url, args.revision) return 0 if args.command == "migration": return _run_migration(args) if args.command == "message-alias": return _run_message_alias(args) if args.command == "conformance": return _run_conformance(args.base_url, args.timeout, args.as_json) raise AssertionError(f"Unhandled command {args.command}") def _run_api(host: str, port: int) -> None: import uvicorn uvicorn.run("hub_core.runtime.app:app", host=host, port=port) def _run_mcp(host: str, port: int, transport: str, api_base: str) -> None: server = HubCoreMCPServer(name="hub-core", api_base=api_base) server.mcp.run(transport=transport, host=host, port=port) def _run_migrations(database_url: str, revision: str) -> None: from alembic import command from alembic.config import Config migration_root = files("hub_core.migrations") config = Config() config.set_main_option("script_location", str(migration_root)) config.set_main_option("sqlalchemy.url", _sync_database_url(database_url)) command.upgrade(config, revision) def _run_conformance(base_url: str, timeout: float, as_json: bool) -> int: import httpx from hub_core.conformance import ConformanceHarness with httpx.Client(base_url=base_url, timeout=timeout) as target: report = ConformanceHarness(target).run() if as_json: print(json.dumps(report.to_dict(), indent=2, sort_keys=True)) else: for check in report.checks: print(f"{check.status.upper():4} Tier {check.tier} {check.check_id}: {check.summary}") print(f"{report.passed_count}/{len(report.checks)} implemented checks passed") return 0 if report.passed else 1 def _run_migration(args: argparse.Namespace) -> int: import asyncio from hub_core.runtime.migration import ( export_bundle, import_bundle, load_bundle, validate_bundle, ) from hub_core.runtime.postgres_store import PostgresPortStore if args.migration_command == "validate": report = validate_bundle(load_bundle(args.bundle)) else: if not args.database_url: raise SystemExit("migration command requires --database-url or HUB_CORE_DATABASE_URL") store = PostgresPortStore.from_url(args.database_url) async def run() -> dict: try: if args.migration_command == "import": return await import_bundle(store, load_bundle(args.bundle), dry_run=args.dry_run) return await export_bundle(store, source_revision=args.source_revision) finally: await store.aclose() report = asyncio.run(run()) rendered = json.dumps(report, indent=2, sort_keys=True) if args.output: args.output.write_text(rendered + "\n") else: print(rendered) return 0 if report.get("ok", True) else 1 def _run_message_alias(args: argparse.Namespace) -> int: import asyncio from dataclasses import asdict from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from hub_core.message_identity import MessageIdentityError, register_message_alias if args.message_alias_command != "register": raise AssertionError(f"Unhandled message alias command {args.message_alias_command}") if not args.confirm: raise SystemExit("message-alias register requires --confirm") if not args.database_url: raise SystemExit( "message-alias register requires --database-url or HUB_CORE_DATABASE_URL" ) async def run() -> dict: engine = create_async_engine(_async_database_url(args.database_url)) sessions = async_sessionmaker(engine, expire_on_commit=False) try: async with sessions() as session: result = await register_message_alias( session, alias=args.alias, message_id=args.message_id, source=args.source, reason=args.reason, created_by=args.created_by, ) report = asdict(result) report["message_id"] = str(result.message_id) report["ok"] = True return report finally: await engine.dispose() try: report = asyncio.run(run()) except MessageIdentityError as exc: print(json.dumps({"ok": False, "error": str(exc)}, sort_keys=True)) return 1 print(json.dumps(report, indent=2, sort_keys=True)) return 0 def _sync_database_url(database_url: str) -> str: return database_url.replace("postgresql+asyncpg://", "postgresql+psycopg2://") def _async_database_url(database_url: str) -> str: if database_url.startswith("postgresql://"): return database_url.replace("postgresql://", "postgresql+asyncpg://", 1) return database_url.replace("postgresql+psycopg2://", "postgresql+asyncpg://", 1)