from __future__ import annotations import json from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING import typer from pydantic import ValidationError if TYPE_CHECKING: from wf_api import ( WorkflowAdminSurface, WorkflowApi, WorkflowApiSurface, WorkflowSourceAdminSurface, WorkflowSourceRegistrySurface, ) from wf_config import WorkflowConfigFile from wf_mcp.broker.service import WfMcpService from wf_server.context import WorkflowServer from wf_transport_rpc_http import RpcWorkflowApiClient @dataclass(frozen=True) class CliContext: """Protocol-neutral CLI handle over local or remote workflow operations.""" config_path: Path service: WfMcpService | None handlers: WorkflowApiSurface source_admin: WorkflowSourceAdminSurface admin: WorkflowAdminSurface source_registry_admin: WorkflowSourceRegistrySurface | None = None verbose: bool = False @dataclass(frozen=True) class LocalCliContext: """CLI context for commands that still require same-process WorkflowApi.""" config_path: Path service: WfMcpService | None handlers: WorkflowApi @dataclass(frozen=True, slots=True) class CliTyperState: """Typed boundary for Typer's untyped `Context.obj` payload. Typer/Click do not make `Context.obj` generic, so every command should read root CLI options through this adapter instead of spelling dict keys locally. """ config_path: str = "wf.config.json" force_local: bool = False rpc_url: str | None = None rpc_timeout_seconds: float | None = None verbose: bool = False @classmethod def from_context(cls, ctx: typer.Context) -> CliTyperState: obj = ctx.obj if isinstance(obj, cls): return obj if isinstance(obj, Mapping): return cls.from_mapping(obj) return cls() @classmethod def from_mapping(cls, obj: Mapping[object, object]) -> CliTyperState: config_path = obj.get("config_path", cls.config_path) rpc_url = obj.get("rpc_url") timeout = obj.get("rpc_timeout_seconds") return cls( config_path=( config_path if isinstance(config_path, str) else cls.config_path ), force_local=bool(obj.get("force_local", cls.force_local)), rpc_url=rpc_url if isinstance(rpc_url, str) else None, rpc_timeout_seconds=( float(timeout) if isinstance(timeout, float | int) else None ), verbose=bool(obj.get("verbose", cls.verbose)), ) def config_path_from_context(ctx: typer.Context) -> str: """Return the root --config path captured by the Typer callback.""" return CliTyperState.from_context(ctx).config_path def build_workflow_server_from_workflow_config( config: WorkflowConfigFile, ) -> WorkflowServer: """Build the local server without importing the server runtime at CLI startup.""" from wf_server.config import build_workflow_server_from_workflow_config as build # Local CLI exposes the full draft command group, so it is an explicit # draft-bearing composition even though the neutral server default is not. return build(config, drafts=True) def load_cli_context( config_path: str | Path, *, force_local: bool = False, rpc_url: str | None = None, rpc_timeout_seconds: float | None = None, verbose: bool = False, ) -> CliContext: """Load config and build workflow-surface handlers for CLI commands.""" resolved_config_path = Path(config_path) if force_local and rpc_url is not None: raise ValueError("--local and --url are mutually exclusive") if rpc_url is not None: # Keep transport construction out of `wf --help`; only commands that # contact a remote target need the RPC client and its runtime stack. _validate_rpc_url(rpc_url) client = rpc_client_from_target( url=rpc_url, timeout_seconds=_rpc_timeout_from_optional_config( resolved_config_path, override=rpc_timeout_seconds, ), ) return CliContext( config_path=resolved_config_path, service=None, handlers=client, source_admin=client, admin=client, source_registry_admin=client, verbose=verbose, ) if _is_legacy_mcp_config(resolved_config_path): from wf_api import ( WorkflowAdminApi, WorkflowApi, WorkflowSourceAdminApi, ) from wf_mcp.broker import build_service_from_config, load_broker_config from wf_mcp.broker.service.workflow_operation_context import ( context_from_service, ) config = load_broker_config(resolved_config_path) service = build_service_from_config(config) return CliContext( config_path=resolved_config_path, service=service, # Legacy MCP CLI commands include draft authoring operations. handlers=WorkflowApi(context_from_service(service), drafts=True), source_admin=WorkflowSourceAdminApi(context_from_service(service)), admin=WorkflowAdminApi( connections=service.connection_service, events=service.events, ), verbose=verbose, ) from wf_api import WorkflowApi from wf_config import ( FilesystemStoreConfig, LocalTargetConfig, RpcHttpTargetConfig, load_workflow_config, ) config = load_workflow_config(resolved_config_path) target = config.client.target if force_local or isinstance(target, LocalTargetConfig): store = config.server.workflow_store if not isinstance(store, FilesystemStoreConfig): raise ValueError("local CLI target currently requires filesystem store") # Local means "same-process WorkflowServer", not "local-only sources". # Configured MCP HTTP/stdio sources may still open external transports. server = build_workflow_server_from_workflow_config(config) return CliContext( config_path=resolved_config_path, service=None, handlers=server.api, source_admin=server.source_admin, admin=server.admin, verbose=verbose, ) if isinstance(target, RpcHttpTargetConfig): client = rpc_client_from_target( url=str(target.url), timeout_seconds=( rpc_timeout_seconds if rpc_timeout_seconds is not None else target.timeout_seconds ), ) return CliContext( config_path=resolved_config_path, service=None, handlers=client, source_admin=client, admin=client, source_registry_admin=client, verbose=verbose, ) raise ValueError(f"unsupported workflow target {target!r}") def rpc_client_from_target( *, url: str, timeout_seconds: float, ) -> RpcWorkflowApiClient: """Build the remote workflow surface for a resolved RPC target. CLI tests patch this project-owned seam instead of monkeypatching `httpx2` internals. The production path still keeps HTTP construction inside the transport package. """ # Importing the client here keeps HTTP/RPC server dependencies out of the # CLI's command registration and help path. from wf_transport_rpc_http import RpcWorkflowApiClient return RpcWorkflowApiClient(url=url, timeout_seconds=timeout_seconds) def load_local_cli_context( config_path: str | Path, *, force_local: bool = False, rpc_url: str | None = None, rpc_timeout_seconds: float | None = None, ) -> LocalCliContext: """Load a local WorkflowApi context for commands not remote-enabled yet.""" context = load_cli_context( config_path, force_local=force_local, rpc_url=rpc_url, rpc_timeout_seconds=rpc_timeout_seconds, ) from wf_api import WorkflowApi if not isinstance(context.handlers, WorkflowApi): raise ValueError( "this CLI command is not available for rpc_http targets yet; " "use --local or run a cap/run command" ) return LocalCliContext( config_path=context.config_path, service=context.service, handlers=context.handlers, ) def force_local_from_context(ctx: typer.Context) -> bool: return CliTyperState.from_context(ctx).force_local def rpc_url_from_context(ctx: typer.Context) -> str | None: return CliTyperState.from_context(ctx).rpc_url def rpc_timeout_from_context(ctx: typer.Context) -> float | None: return CliTyperState.from_context(ctx).rpc_timeout_seconds def load_cli_context_from_typer(ctx: typer.Context) -> CliContext: try: return load_cli_context( config_path_from_context(ctx), force_local=force_local_from_context(ctx), rpc_url=rpc_url_from_context(ctx), rpc_timeout_seconds=rpc_timeout_from_context(ctx), verbose=CliTyperState.from_context(ctx).verbose, ) except ValueError as exc: raise typer.BadParameter(str(exc)) from exc def load_local_cli_context_from_typer(ctx: typer.Context) -> LocalCliContext: try: return load_local_cli_context( config_path_from_context(ctx), force_local=force_local_from_context(ctx), rpc_url=rpc_url_from_context(ctx), rpc_timeout_seconds=rpc_timeout_from_context(ctx), ) except ValueError as exc: raise typer.BadParameter(str(exc)) from exc def _is_legacy_mcp_config(path: Path) -> bool: """Detect legacy broker config by content, not filename. This keeps `wf_mcp.config.json` compatibility without making the filename a load-bearing part of the neutral workflow config migration. """ data = json.loads(path.read_text(encoding="utf-8")) if not isinstance(data, dict): return False if any(key in data for key in ("version", "client", "server")): return False return any(key in data for key in ("store_root", "connections")) def _rpc_timeout_from_optional_config( path: Path, *, override: float | None, ) -> float: from wf_config import RpcHttpTargetConfig, load_workflow_config if override is not None: return override try: config = load_workflow_config(path) except FileNotFoundError, json.JSONDecodeError, ValidationError: return 30.0 target = config.client.target if isinstance(target, RpcHttpTargetConfig): return target.timeout_seconds return 30.0 def _validate_rpc_url(url: str) -> None: if not url.startswith(("http://", "https://")): raise ValueError("rpc url must start with http:// or https://")