Files
lda-wf/docs/historical/superpowers/plans/2026-06-03-workflow-config-and-rpc-cli-target.md
T

41 KiB

Workflow Config and RPC CLI Target Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Target Selection Precedence

  1. --url CLI override selects an RPC HTTP target.
  2. --local CLI override selects the in-process local target.
  3. Config file client.target selects the configured target.
  4. Missing target config defaults to local.

CLI overrides intentionally win over config so one-off diagnostics can point at a different server without editing the config file.

Goal: Add neutral workflow config models and let selected wf CLI commands target either local execution or the JSON-RPC HTTP server.

Architecture: Introduce wf_config as the protocol-neutral config package. Keep existing wf_mcp.config.json loading for compatibility, but add a new wf.json-style shape with client.target, server.store, server.transports, and bootstrap server.sources. Put the JSON-RPC client adapter in wf_transport_rpc_http.client; CLI context chooses local WorkflowApi or remote RPC adapter based on config plus CLI overrides.

Tech Stack: Python 3.14, Pydantic v2 discriminated unions, Typer, httpx, pytest, ruff, basedpyright.


Scope

This is the first config/remote CLI slice.

In scope:

  • Neutral config models and loader.
  • Filesystem store config only.
  • client.target.kind = "local" and client.target.kind = "rpc_http".
  • server.transports.kind = "rpc_http" for wf-rpc-server.
  • server.sources.kind = "stdlib" only.
  • CLI overrides: --local, --url, --timeout.
  • Remote CLI support for:
    • wf cap list
    • wf cap inspect
    • wf run start
    • wf run inspect
    • wf run trace

Out of scope:

  • Remote draft/artifact/deployment CLI commands.
  • Store-backed source registry.
  • MCP source config migration.
  • /mcp hosting.
  • Auth.
  • SQL store.
  • WebSocket/progress streaming.

File Map

  • Create src/wf_config/__init__.py
    • Public exports for config models and loader.
  • Create src/wf_config/models.py
    • Pydantic tagged unions for target, store, transport, and source config.
  • Create src/wf_config/loader.py
    • Load neutral config from JSON, resolve relative filesystem paths relative to config file.
    • Compatibility helper for old MCP config can stay in CLI context rather than here.
  • Create tests/wf_config/test_models.py
    • Unit tests for discriminated unions, duplicate source ids, relative store paths.
  • Create src/wf_transport_rpc_http/client.py
    • RpcWorkflowApiClient with the subset of WorkflowApi methods needed by CLI.
  • Create tests/wf_transport_rpc_http/test_client.py
    • ASGI-backed client tests against create_rpc_app.
  • Modify src/wf_cli/context.py
    • Add target selection and return either local WorkflowApi or RpcWorkflowApiClient.
    • Keep old wf_mcp.config.json compatibility path.
  • Modify src/wf_cli/app.py
    • Add root options --local, --url, --timeout.
  • Modify src/wf_cli/commands/caps.py
    • No behavior change expected; it should keep using context.handlers.
  • Modify src/wf_cli/commands/runs.py
    • No behavior change expected; it should keep using context.handlers.
  • Modify src/wf_transport_rpc_http/cli.py
    • Accept --config; read server.store and first rpc_http transport when supplied.
    • Keep existing direct --store-root, --host, --port flags as overrides.
  • Create or modify tests/wf_cli/test_remote_target.py
    • Validate remote target routing for cap/run commands.
  • Modify docs/superpowers/specs/2026-06-03-workflow-config-targets-and-sources.md
    • Mark first implementation status after code lands.
  • Modify docs/current_roadmap.md
    • Add completion note for neutral config + remote CLI target slice.

Target Interface Shape

CliContext.handlers currently contains WorkflowApi. After this slice it may contain either:

WorkflowApi | RpcWorkflowApiClient

The remote client only needs to implement the methods used by the first remote CLI slice:

async def list_capabilities(...)
async def inspect_capability(...)
async def run_deployment(...)
async def inspect_run(...)
async def read_run_trace(...)

Do not make a fake full WorkflowApi implementation in this slice. Add methods only when commands use them.


Task 1: Add Neutral Config Models

Files:

  • Create: src/wf_config/__init__.py

  • Create: src/wf_config/models.py

  • Create: tests/wf_config/test_models.py

  • Step 1: Write failing config model tests

Create tests/wf_config/test_models.py:

from __future__ import annotations

import pytest
from pydantic import ValidationError

from wf_config import (
    FilesystemStoreConfig,
    LocalTargetConfig,
    RpcHttpTargetConfig,
    RpcHttpTransportConfig,
    StdlibSourceConfig,
    WorkflowConfigFile,
)


def test_workflow_config_parses_local_target_and_filesystem_store() -> None:
    config = WorkflowConfigFile.model_validate(
        {
            "version": 1,
            "client": {"target": {"kind": "local"}},
            "server": {
                "store": {"kind": "filesystem", "root": ".wf_store"},
                "sources": [{"kind": "stdlib", "id": "wf.std"}],
            },
        }
    )

    assert isinstance(config.client.target, LocalTargetConfig)
    assert isinstance(config.server.store, FilesystemStoreConfig)
    assert config.server.store.root.as_posix() == ".wf_store"
    assert isinstance(config.server.sources[0], StdlibSourceConfig)
    assert config.server.sources[0].id == "wf.std"


def test_workflow_config_parses_rpc_http_target_and_transport() -> None:
    config = WorkflowConfigFile.model_validate(
        {
            "version": 1,
            "client": {
                "target": {
                    "kind": "rpc_http",
                    "url": "http://127.0.0.1:8765/rpc",
                    "timeout_seconds": 12,
                }
            },
            "server": {
                "transports": [
                    {
                        "kind": "rpc_http",
                        "host": "0.0.0.0",
                        "port": 9999,
                        "path": "/rpc",
                    }
                ]
            },
        }
    )

    assert isinstance(config.client.target, RpcHttpTargetConfig)
    assert config.client.target.url == "http://127.0.0.1:8765/rpc"
    assert config.client.target.timeout_seconds == 12
    assert isinstance(config.server.transports[0], RpcHttpTransportConfig)
    assert config.server.transports[0].host == "0.0.0.0"
    assert config.server.transports[0].port == 9999


def test_workflow_config_rejects_duplicate_source_ids() -> None:
    with pytest.raises(ValidationError, match="duplicate source id"):
        WorkflowConfigFile.model_validate(
            {
                "version": 1,
                "server": {
                    "sources": [
                        {"kind": "stdlib", "id": "wf.std"},
                        {"kind": "stdlib", "id": "wf.std"},
                    ]
                },
            }
        )


def test_workflow_config_rejects_unknown_target_kind() -> None:
    with pytest.raises(ValidationError):
        WorkflowConfigFile.model_validate(
            {
                "version": 1,
                "client": {"target": {"kind": "mcp"}},
            }
        )
  • Step 2: Run test to verify failure

Run:

uv run pytest tests/wf_config/test_models.py -q

Expected: fail because wf_config does not exist.

  • Step 3: Implement models

Create src/wf_config/models.py:

from __future__ import annotations

from pathlib import Path
from typing import Annotated, Literal

from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator


class WorkflowConfigModel(BaseModel):
    """Base config model: reject typos so config mistakes fail fast."""

    model_config = ConfigDict(extra="forbid")


class LocalTargetConfig(WorkflowConfigModel):
    kind: Literal["local"] = "local"


class RpcHttpTargetConfig(WorkflowConfigModel):
    kind: Literal["rpc_http"]
    url: str
    timeout_seconds: float = Field(default=30.0, gt=0)


TargetConfig = Annotated[
    LocalTargetConfig | RpcHttpTargetConfig,
    Field(discriminator="kind"),
]


class ClientConfig(WorkflowConfigModel):
    target: TargetConfig = Field(default_factory=LocalTargetConfig)


class FilesystemStoreConfig(WorkflowConfigModel):
    kind: Literal["filesystem"] = "filesystem"
    root: Path = Path(".wf_store")


StoreConfig = Annotated[
    FilesystemStoreConfig,
    Field(discriminator="kind"),
]


class RpcHttpTransportConfig(WorkflowConfigModel):
    kind: Literal["rpc_http"]
    host: str = "127.0.0.1"
    port: int = Field(default=8765, ge=1, le=65535)
    path: str = "/rpc"

    @field_validator("path")
    @classmethod
    def validate_path(cls, value: str) -> str:
        if not value.startswith("/"):
            raise ValueError("transport path must start with '/'")
        return value


ServerTransportConfig = Annotated[
    RpcHttpTransportConfig,
    Field(discriminator="kind"),
]


class StdlibSourceConfig(WorkflowConfigModel):
    kind: Literal["stdlib"]
    id: str = Field(min_length=1)


SourceConfig = Annotated[
    StdlibSourceConfig,
    Field(discriminator="kind"),
]


class ServerConfig(WorkflowConfigModel):
    store: StoreConfig = Field(default_factory=FilesystemStoreConfig)
    transports: list[ServerTransportConfig] = Field(default_factory=list)
    sources: list[SourceConfig] = Field(default_factory=list)

    @model_validator(mode="after")
    def validate_unique_source_ids(self) -> ServerConfig:
        seen: set[str] = set()
        for source in self.sources:
            if source.id in seen:
                raise ValueError(f"duplicate source id {source.id!r}")
            seen.add(source.id)
        return self


class WorkflowConfigFile(WorkflowConfigModel):
    version: Literal[1] = 1
    client: ClientConfig = Field(default_factory=ClientConfig)
    server: ServerConfig = Field(default_factory=ServerConfig)

Create src/wf_config/__init__.py:

from __future__ import annotations

from .models import (
    ClientConfig,
    FilesystemStoreConfig,
    LocalTargetConfig,
    RpcHttpTargetConfig,
    RpcHttpTransportConfig,
    ServerConfig,
    StdlibSourceConfig,
    WorkflowConfigFile,
)

__all__ = [
    "ClientConfig",
    "FilesystemStoreConfig",
    "LocalTargetConfig",
    "RpcHttpTargetConfig",
    "RpcHttpTransportConfig",
    "ServerConfig",
    "StdlibSourceConfig",
    "WorkflowConfigFile",
]
  • Step 4: Run focused tests

Run:

uv run pytest tests/wf_config/test_models.py -q

Expected: pass.

  • Step 5: Commit
git add src/wf_config tests/wf_config/test_models.py
git commit -m "feat: add neutral workflow config models"

Task 2: Add Config Loader and Relative Path Resolution

Files:

  • Create: src/wf_config/loader.py

  • Modify: src/wf_config/__init__.py

  • Modify: tests/wf_config/test_models.py

  • Step 1: Add loader tests

Append to tests/wf_config/test_models.py:

import json

from wf_config import load_workflow_config


def test_load_workflow_config_resolves_filesystem_store_relative_to_config(
    tmp_path,
) -> None:
    config_path = tmp_path / "wf.json"
    config_path.write_text(
        json.dumps(
            {
                "version": 1,
                "server": {
                    "store": {"kind": "filesystem", "root": ".wf_store"},
                },
            }
        ),
        encoding="utf-8",
    )

    config = load_workflow_config(config_path)

    assert config.server.store.root == (tmp_path / ".wf_store").resolve()


def test_load_workflow_config_preserves_absolute_filesystem_store(tmp_path) -> None:
    absolute_root = (tmp_path / "absolute-store").resolve()
    config_path = tmp_path / "wf.json"
    config_path.write_text(
        json.dumps(
            {
                "version": 1,
                "server": {
                    "store": {"kind": "filesystem", "root": str(absolute_root)},
                },
            }
        ),
        encoding="utf-8",
    )

    config = load_workflow_config(config_path)

    assert config.server.store.root == absolute_root
  • Step 2: Run tests to verify failure

Run:

uv run pytest tests/wf_config/test_models.py::test_load_workflow_config_resolves_filesystem_store_relative_to_config -q

Expected: fail because load_workflow_config does not exist.

  • Step 3: Implement loader

Create src/wf_config/loader.py:

from __future__ import annotations

import json
from pathlib import Path

from .models import FilesystemStoreConfig, WorkflowConfigFile


def load_workflow_config(path: str | Path) -> WorkflowConfigFile:
    """Load neutral workflow config and resolve local filesystem paths.

    Relative filesystem store roots are config-file relative so `wf --config`
    behaves the same regardless of the caller's current working directory.
    """

    config_path = Path(path)
    data = json.loads(config_path.read_text(encoding="utf-8"))
    config = WorkflowConfigFile.model_validate(data)
    store = config.server.store
    if isinstance(store, FilesystemStoreConfig) and not store.root.is_absolute():
        config = config.model_copy(
            update={
                "server": config.server.model_copy(
                    update={
                        "store": store.model_copy(
                            update={"root": (config_path.parent / store.root).resolve()}
                        )
                    }
                )
            }
        )
    return config
  • Step 4: Export loader

Update src/wf_config/__init__.py:

from .loader import load_workflow_config

Add "load_workflow_config" to __all__.

  • Step 5: Run tests

Run:

uv run pytest tests/wf_config/test_models.py -q

Expected: pass.

  • Step 6: Commit
git add src/wf_config tests/wf_config/test_models.py
git commit -m "feat: load neutral workflow config"

Task 3: Add JSON-RPC Workflow API Client

Files:

  • Create: src/wf_transport_rpc_http/client.py

  • Modify: src/wf_transport_rpc_http/__init__.py

  • Create: tests/wf_transport_rpc_http/test_client.py

  • Step 1: Write client tests

Create tests/wf_transport_rpc_http/test_client.py:

from __future__ import annotations

import asyncio
from typing import Any

import httpx

from wf_api.models import RawWorkflowPlan, TraceRange
from wf_core import END
from wf_server import build_local_static_workflow_server
from wf_transport_rpc_http import RpcWorkflowApiClient, create_rpc_app


def _constant_plan() -> RawWorkflowPlan:
    return RawWorkflowPlan.model_validate(
        {
            "name": "client_constant",
            "input_schema": {"type": "object", "properties": {}},
            "state_schema": {
                "type": "object",
                "properties": {
                    "result": {"type": "string", "reducer": "wf.std.replace"}
                },
            },
            "output_schema": {
                "type": "object",
                "properties": {"result": {"type": "string"}},
                "required": ["result"],
            },
            "outcomes": ["ok"],
            "start": "constant",
            "nodes": [
                {
                    "id": "constant",
                    "type": "node",
                    "node": "wf.std.constant",
                    "input": [
                        {
                            "value": "hello from rpc client",
                            "target": {"root": "local", "parts": ["value"]},
                        }
                    ],
                    "output": [
                        {
                            "source": {"root": "local", "parts": ["value"]},
                            "target": {"root": "state", "parts": ["result"]},
                        }
                    ],
                }
            ],
            "edges": [{"from": "constant", "outcome": "ok", "to": END}],
            "output": [
                {
                    "path": {"root": "state", "parts": ["result"]},
                    "target": {"root": "local", "parts": ["result"]},
                }
            ],
        }
    )


def test_rpc_workflow_client_lists_and_inspects_capabilities(tmp_path) -> None:
    async def scenario() -> None:
        server = build_local_static_workflow_server(tmp_path / "store")
        app = create_rpc_app(server)
        transport = httpx.ASGITransport(app=app)
        async with httpx.AsyncClient(
            transport=transport,
            base_url="http://test",
        ) as http_client:
            client = RpcWorkflowApiClient(
                url="http://test/rpc",
                timeout_seconds=5,
                http_client=http_client,
            )
            listed = await client.list_capabilities(source_id="wf.std", limit=5)
            inspected = await client.inspect_capability(
                qualified_name="wf.std.constant"
            )

        assert listed["capabilities"]
        assert inspected["name"] == "wf.std.constant"

    asyncio.run(scenario())


def test_rpc_workflow_client_runs_and_reads_trace(tmp_path) -> None:
    async def scenario() -> None:
        server = build_local_static_workflow_server(tmp_path / "store")
        await server.api.create_artifact_from_plan(
            artifact_id="client_constant",
            version=1,
            title="Client Constant",
            plan=_constant_plan(),
            outcomes=["ok"],
            source_bindings={"wf.std": "wf.std"},
        )
        await server.api.save_deployment(
            {
                "id": "client_constant.default",
                "artifact_id": "client_constant",
                "artifact_version": 1,
                "bindings": [{"logical_source": "wf.std", "concrete_source": "wf.std"}],
            }
        )
        app = create_rpc_app(server)
        transport = httpx.ASGITransport(app=app)
        async with httpx.AsyncClient(
            transport=transport,
            base_url="http://test",
        ) as http_client:
            client = RpcWorkflowApiClient(
                url="http://test/rpc",
                timeout_seconds=5,
                http_client=http_client,
            )
            run = await client.run_deployment(
                deployment_id="client_constant.default",
                workflow_input={},
                trace_range=TraceRange(start=0, limit=1),
            )
            inspected = await client.inspect_run(run_id=run["run_id"])
            trace = await client.read_run_trace(
                run_id=run["run_id"],
                trace_range=TraceRange(start=0, limit=1),
            )

        assert run["status"] == "completed"
        assert run["output"]["result"] == "hello from rpc client"
        assert inspected["trace_count"] >= 1
        assert len(trace["trace"]) == 1

    asyncio.run(scenario())


def test_rpc_workflow_client_raises_for_rpc_error(tmp_path) -> None:
    async def scenario() -> None:
        server = build_local_static_workflow_server(tmp_path / "store")
        app = create_rpc_app(server)
        transport = httpx.ASGITransport(app=app)
        async with httpx.AsyncClient(
            transport=transport,
            base_url="http://test",
        ) as http_client:
            client = RpcWorkflowApiClient(
                url="http://test/rpc",
                timeout_seconds=5,
                http_client=http_client,
            )
            try:
                await client.inspect_capability(qualified_name="missing.capability")
            except RuntimeError as exc:
                message = str(exc)
            else:
                raise AssertionError("expected RuntimeError")

        assert "Workflow operation failed" in message
        assert "missing.capability" in message

    asyncio.run(scenario())
  • Step 2: Run tests to verify failure

Run:

uv run pytest tests/wf_transport_rpc_http/test_client.py -q

Expected: fail because RpcWorkflowApiClient does not exist.

  • Step 3: Implement client

Create src/wf_transport_rpc_http/client.py:

from __future__ import annotations

from dataclasses import dataclass
from typing import Any
from uuid import uuid4

import httpx

from wf_api.models import TraceRange


@dataclass(slots=True)
class RpcWorkflowApiClient:
    """Small WorkflowApi-compatible adapter for JSON-RPC HTTP targets.

    This is intentionally not a full WorkflowApi clone. It implements only the
    methods used by the first remote CLI slice.
    """

    url: str
    timeout_seconds: float = 30.0
    http_client: httpx.AsyncClient | None = None

    async def _call(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
        request = {
            "jsonrpc": "2.0",
            "id": uuid4().hex,
            "method": method,
            "params": params,
        }
        if self.http_client is None:
            async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
                response = await client.post(self.url, json=request)
        else:
            response = await self.http_client.post(self.url, json=request)
        response.raise_for_status()
        payload = response.json()
        if "error" in payload:
            error = payload["error"]
            message = error.get("message", "JSON-RPC error")
            data = error.get("data")
            if isinstance(data, dict) and data.get("message"):
                message = f"{message}: {data['message']}"
            raise RuntimeError(message)
        result = payload.get("result")
        if not isinstance(result, dict):
            raise RuntimeError("JSON-RPC response result must be an object")
        return result

    async def list_capabilities(
        self,
        *,
        query: str | None = None,
        source_id: str | None = None,
        cursor: str | None = None,
        limit: int = 50,
    ) -> dict[str, Any]:
        return await self._call(
            "workflow.capabilities.list",
            {
                "query": query,
                "source_id": source_id,
                "cursor": cursor,
                "limit": limit,
            },
        )

    async def inspect_capability(self, *, qualified_name: str) -> dict[str, Any]:
        return await self._call(
            "workflow.capabilities.inspect",
            {"qualified_name": qualified_name},
        )

    async def run_deployment(
        self,
        *,
        deployment_id: str,
        workflow_input: dict[str, Any],
        trace_range: TraceRange | None = None,
    ) -> dict[str, Any]:
        return await self._call(
            "workflow.runs.start",
            {
                "deployment_id": deployment_id,
                "workflow_input": workflow_input,
                "trace_range": _trace_range_payload(trace_range),
            },
        )

    async def inspect_run(self, *, run_id: str) -> dict[str, Any]:
        return await self._call("workflow.runs.inspect", {"run_id": run_id})

    async def read_run_trace(
        self,
        *,
        run_id: str,
        trace_range: TraceRange,
    ) -> dict[str, Any]:
        return await self._call(
            "workflow.runs.trace",
            {
                "run_id": run_id,
                "trace_range": _trace_range_payload(trace_range),
            },
        )


def _trace_range_payload(trace_range: TraceRange | None) -> dict[str, int] | None:
    if trace_range is None:
        return None
    return {"start": trace_range.start, "limit": trace_range.limit}
  • Step 4: Export client

Update src/wf_transport_rpc_http/__init__.py:

from .client import RpcWorkflowApiClient

Add "RpcWorkflowApiClient" to __all__.

  • Step 5: Run client tests

Run:

uv run pytest tests/wf_transport_rpc_http/test_client.py -q

Expected: pass.

  • Step 6: Commit
git add src/wf_transport_rpc_http tests/wf_transport_rpc_http/test_client.py
git commit -m "feat: add workflow rpc http client"

Task 4: Route CLI Context by Neutral Target Config

Files:

  • Modify: src/wf_cli/context.py

  • Modify: src/wf_cli/app.py

  • Create: tests/wf_cli/test_remote_target.py

  • Step 1: Add remote target tests

Create tests/wf_cli/test_remote_target.py:

from __future__ import annotations

import json

from wf_cli.context import load_cli_context
from wf_transport_rpc_http import RpcWorkflowApiClient


def test_load_cli_context_uses_rpc_client_for_rpc_http_target(tmp_path) -> None:
    config_path = tmp_path / "wf.json"
    config_path.write_text(
        json.dumps(
            {
                "version": 1,
                "client": {
                    "target": {
                        "kind": "rpc_http",
                        "url": "http://127.0.0.1:8765/rpc",
                        "timeout_seconds": 9,
                    }
                },
            }
        ),
        encoding="utf-8",
    )

    context = load_cli_context(config_path)

    assert isinstance(context.handlers, RpcWorkflowApiClient)
    assert context.handlers.url == "http://127.0.0.1:8765/rpc"
    assert context.handlers.timeout_seconds == 9
    assert context.service is None


def test_load_cli_context_local_override_beats_rpc_config(tmp_path) -> None:
    config_path = tmp_path / "wf.json"
    config_path.write_text(
        json.dumps(
            {
                "version": 1,
                "client": {
                    "target": {
                        "kind": "rpc_http",
                        "url": "http://127.0.0.1:8765/rpc",
                    }
                },
                "server": {
                    "store": {"kind": "filesystem", "root": ".wf_store"},
                },
            }
        ),
        encoding="utf-8",
    )

    context = load_cli_context(config_path, force_local=True)

    assert not isinstance(context.handlers, RpcWorkflowApiClient)
    assert context.service is None
    assert context.config_path == config_path
  • Step 2: Run tests to verify failure

Run:

uv run pytest tests/wf_cli/test_remote_target.py -q

Expected: fail because load_cli_context does not understand neutral config/remote targets.

  • Step 3: Update CLI context

Modify src/wf_cli/context.py.

Replace CliContext with:

@dataclass(frozen=True)
class CliContext:
    """Protocol-neutral CLI handle over local or remote workflow operations."""

    config_path: Path
    service: WfMcpService | None
    handlers: WorkflowApi | RpcWorkflowApiClient

Add imports:

from wf_config import FilesystemStoreConfig, LocalTargetConfig, RpcHttpTargetConfig, load_workflow_config
from wf_server import build_local_static_workflow_server
from wf_transport_rpc_http import RpcWorkflowApiClient

Change load_cli_context signature:

def load_cli_context(
    config_path: str | Path,
    *,
    force_local: bool = False,
    rpc_url: str | None = None,
    rpc_timeout_seconds: float | None = None,
) -> CliContext:

Implement this logic:

    resolved_config_path = Path(config_path)
    if resolved_config_path.name == "wf_mcp.config.json":
        config = load_broker_config(resolved_config_path)
        service = build_service_from_config(config)
        return CliContext(
            config_path=resolved_config_path,
            service=service,
            handlers=WorkflowApi(context_from_service(service)),
        )

    config = load_workflow_config(resolved_config_path)
    target = config.client.target
    if rpc_url is not None:
        timeout = rpc_timeout_seconds if rpc_timeout_seconds is not None else 30.0
        return CliContext(
            config_path=resolved_config_path,
            service=None,
            handlers=RpcWorkflowApiClient(url=rpc_url, timeout_seconds=timeout),
        )
    if force_local or isinstance(target, LocalTargetConfig):
        store = config.server.store
        if not isinstance(store, FilesystemStoreConfig):
            raise ValueError("local CLI target currently requires filesystem store")
        server = build_local_static_workflow_server(store.root)
        return CliContext(
            config_path=resolved_config_path,
            service=None,
            handlers=server.api,
        )
    if isinstance(target, RpcHttpTargetConfig):
        return CliContext(
            config_path=resolved_config_path,
            service=None,
            handlers=RpcWorkflowApiClient(
                url=target.url,
                timeout_seconds=(
                    rpc_timeout_seconds
                    if rpc_timeout_seconds is not None
                    else target.timeout_seconds
                ),
            ),
        )
    raise ValueError(f"unsupported workflow target {target!r}")

Keep config_path_from_context for compatibility.

  • Step 4: Add root CLI override plumbing

Modify src/wf_cli/app.py root callback to accept:

    local: Annotated[
        bool,
        typer.Option("--local", help="Force same-process local workflow target."),
    ] = False,
    url: Annotated[
        str | None,
        typer.Option("--url", help="Override workflow JSON-RPC target URL."),
    ] = None,
    timeout: Annotated[
        float | None,
        typer.Option("--timeout", min=0.1, help="Override RPC timeout seconds."),
    ] = None,

Set:

ctx.obj = {
    "config_path": config,
    "force_local": local,
    "rpc_url": url,
    "rpc_timeout_seconds": timeout,
}

Add helper functions in src/wf_cli/context.py:

def force_local_from_context(ctx: typer.Context) -> bool:
    obj = ctx.obj if isinstance(ctx.obj, dict) else {}
    return bool(obj.get("force_local", False))


def rpc_url_from_context(ctx: typer.Context) -> str | None:
    obj = ctx.obj if isinstance(ctx.obj, dict) else {}
    value = obj.get("rpc_url")
    return value if isinstance(value, str) else None


def rpc_timeout_from_context(ctx: typer.Context) -> float | None:
    obj = ctx.obj if isinstance(ctx.obj, dict) else {}
    value = obj.get("rpc_timeout_seconds")
    return value if isinstance(value, float | int) else None


def load_cli_context_from_typer(ctx: typer.Context) -> CliContext:
    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),
    )

Do not update every command manually yet; Task 5 does the selected commands.

  • Step 5: Run tests

Run:

uv run pytest tests/wf_cli/test_remote_target.py tests/wf_cli/test_context.py -q

Expected: pass.

  • Step 6: Commit
git add src/wf_cli/context.py src/wf_cli/app.py tests/wf_cli/test_remote_target.py
git commit -m "feat: select workflow cli target from config"

Task 5: Wire Selected CLI Commands Through Target-Aware Context

Files:

  • Modify: src/wf_cli/commands/caps.py

  • Modify: src/wf_cli/commands/runs.py

  • Modify: tests/wf_cli/test_remote_target.py

  • Step 1: Add CLI remote cap/run tests

Append to tests/wf_cli/test_remote_target.py:

import asyncio

import httpx
from typer.testing import CliRunner

from wf_api.models import RawWorkflowPlan
from wf_cli.app import app
from wf_core import END
from wf_server import build_local_static_workflow_server
from wf_transport_rpc_http import create_rpc_app


def _constant_plan() -> RawWorkflowPlan:
    return RawWorkflowPlan.model_validate(
        {
            "name": "remote_cli_constant",
            "input_schema": {"type": "object", "properties": {}},
            "state_schema": {
                "type": "object",
                "properties": {
                    "result": {"type": "string", "reducer": "wf.std.replace"}
                },
            },
            "output_schema": {
                "type": "object",
                "properties": {"result": {"type": "string"}},
                "required": ["result"],
            },
            "outcomes": ["ok"],
            "start": "constant",
            "nodes": [
                {
                    "id": "constant",
                    "type": "node",
                    "node": "wf.std.constant",
                    "input": [
                        {
                            "value": "hello remote cli",
                            "target": {"root": "local", "parts": ["value"]},
                        }
                    ],
                    "output": [
                        {
                            "source": {"root": "local", "parts": ["value"]},
                            "target": {"root": "state", "parts": ["result"]},
                        }
                    ],
                }
            ],
            "edges": [{"from": "constant", "outcome": "ok", "to": END}],
            "output": [
                {
                    "path": {"root": "state", "parts": ["result"]},
                    "target": {"root": "local", "parts": ["result"]},
                }
            ],
        }
    )


def test_wf_cap_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None:
    async def scenario() -> None:
        server = build_local_static_workflow_server(tmp_path / "store")
        rpc_app = create_rpc_app(server)
        transport = httpx.ASGITransport(app=rpc_app)
        client = httpx.AsyncClient(transport=transport, base_url="http://test")
        monkeypatch.setattr(
            "wf_transport_rpc_http.client.httpx.AsyncClient",
            lambda *args, **kwargs: client,
        )
        config_path = tmp_path / "wf.json"
        config_path.write_text('{"version": 1}', encoding="utf-8")

        result = CliRunner().invoke(
            app,
            [
                "--config",
                str(config_path),
                "--url",
                "http://test/rpc",
                "cap",
                "inspect",
                "wf.std.constant",
            ],
        )
        await client.aclose()
        assert result.exit_code == 0, result.output
        assert '"name": "wf.std.constant"' in result.output

    asyncio.run(scenario())

This test uses monkeypatching because Typer commands own asyncio.run; keep it focused to prove the context path, not the whole HTTP stack.

  • Step 2: Run test to verify failure

Run:

uv run pytest tests/wf_cli/test_remote_target.py::test_wf_cap_commands_use_rpc_url_override -q

Expected: fail because commands still call load_cli_context(config_path_from_context(ctx)).

  • Step 3: Update selected commands

In src/wf_cli/commands/caps.py, replace:

from wf_cli.context import config_path_from_context, load_cli_context

with:

from wf_cli.context import load_cli_context_from_typer

Replace both:

context = load_cli_context(config_path_from_context(ctx))

with:

context = load_cli_context_from_typer(ctx)

In src/wf_cli/commands/runs.py, do the same replacement.

  • Step 4: Run focused CLI tests

Run:

uv run pytest tests/wf_cli/test_remote_target.py tests/wf_cli/test_run_deploy.py tests/wf_cli/test_discovery_lifecycle.py -q

Expected: pass.

  • Step 5: Commit
git add src/wf_cli/commands/caps.py src/wf_cli/commands/runs.py tests/wf_cli/test_remote_target.py
git commit -m "feat: route cap and run cli commands to rpc targets"

Task 6: Read Server Config in wf-rpc-server

Files:

  • Modify: src/wf_transport_rpc_http/cli.py

  • Modify: tests/wf_transport_rpc_http/test_cli.py

  • Step 1: Add CLI config test

Append to tests/wf_transport_rpc_http/test_cli.py:

import json


def test_rpc_server_cli_accepts_config_file(tmp_path) -> None:
    config_path = tmp_path / "wf.json"
    config_path.write_text(
        json.dumps(
            {
                "version": 1,
                "server": {
                    "store": {"kind": "filesystem", "root": ".wf_store"},
                    "transports": [
                        {
                            "kind": "rpc_http",
                            "host": "127.0.0.1",
                            "port": 9999,
                            "path": "/rpc",
                        }
                    ],
                },
            }
        ),
        encoding="utf-8",
    )

    result = CliRunner().invoke(app, ["--config", str(config_path), "--help"])

    assert result.exit_code == 0
    assert "--config" in result.output
  • Step 2: Run test to verify failure

Run:

uv run pytest tests/wf_transport_rpc_http/test_cli.py -q

Expected: fail because --config does not exist.

  • Step 3: Update CLI options

Modify src/wf_transport_rpc_http/cli.py:

  • Add imports:
from wf_config import FilesystemStoreConfig, RpcHttpTransportConfig, load_workflow_config
  • Change options:
    config: Path | None = typer.Option(
        None,
        "--config",
        help="Path to neutral workflow config JSON.",
    ),
    store_root: Path | None = typer.Option(
        None,
        "--store-root",
        help="Override filesystem workflow store root.",
    ),
  • Resolve values:
    resolved_store_root = store_root
    resolved_host = host
    resolved_port = port
    if config is not None:
        workflow_config = load_workflow_config(config)
        store = workflow_config.server.store
        if not isinstance(store, FilesystemStoreConfig):
            raise typer.BadParameter("wf-rpc-server currently requires filesystem store")
        resolved_store_root = resolved_store_root or store.root
        rpc_transport = next(
            (
                transport
                for transport in workflow_config.server.transports
                if isinstance(transport, RpcHttpTransportConfig)
            ),
            None,
        )
        if rpc_transport is not None:
            resolved_host = host or rpc_transport.host
            resolved_port = port or rpc_transport.port
    if resolved_store_root is None:
        raise typer.BadParameter("--store-root is required when --config is not supplied")

To make host/port overrides work, change defaults to None:

host: str | None = typer.Option(None, "--host")
port: int | None = typer.Option(None, "--port", min=1, max=65535)

Pass defaults to uvicorn:

uvicorn.run(
    rpc_app,
    host=resolved_host or "127.0.0.1",
    port=resolved_port or 8765,
    access_log=False,
)
  • Step 4: Run CLI tests

Run:

uv run pytest tests/wf_transport_rpc_http/test_cli.py -q

Expected: pass.

  • Step 5: Commit
git add src/wf_transport_rpc_http/cli.py tests/wf_transport_rpc_http/test_cli.py
git commit -m "feat: load rpc server config"

Task 7: Documentation and Verification

Files:

  • Modify: docs/superpowers/specs/2026-06-03-workflow-config-targets-and-sources.md

  • Modify: docs/current_roadmap.md

  • Step 1: Update spec implementation status

In docs/superpowers/specs/2026-06-03-workflow-config-targets-and-sources.md, add:

## Implementation Status

First slice implemented:

- neutral `wf_config` models and loader
- filesystem server store config
- stdlib source bootstrap config
- local and JSON-RPC client targets
- `wf` root overrides for `--local`, `--url`, and `--timeout`
- remote JSON-RPC client support for capability and run CLI commands
- `wf-rpc-server --config` support for server store and RPC HTTP transport

Still future:

- store-backed mutable source registry
- MCP/OpenAPI source config
- `/mcp` hosting from neutral server config
- remote draft/artifact/deployment CLI commands
- auth and SQL stores
  • Step 2: Update roadmap

In docs/current_roadmap.md, add:

- Completed: workflow config now distinguishes client targets from server
  hosting config, and selected `wf` commands can target JSON-RPC HTTP with
  explicit CLI overrides.
  • Step 3: Run focused verification

Run:

uv run pytest tests/wf_config tests/wf_transport_rpc_http tests/wf_cli/test_remote_target.py tests/wf_cli/test_run_deploy.py tests/wf_cli/test_discovery_lifecycle.py -q

Expected: pass.

  • Step 4: Run full verification

Run:

uv run pytest -q
uv run ruff check
uv run ruff format --check
uv run basedpyright --level error

Expected:

  • pytest passes with existing skip/xfail count.

  • ruff passes.

  • basedpyright has 0 errors. If the known file enumeration warning appears with exit code 1 but 0 errors, report it explicitly.

  • Step 5: Commit

git add docs/superpowers/specs/2026-06-03-workflow-config-targets-and-sources.md docs/current_roadmap.md
git commit -m "docs: record workflow config target slice"

Self-Review Notes

Spec coverage:

  • client.target default and overrides: Tasks 1, 4, 5.
  • server.store as tagged union: Tasks 1, 2, 6.
  • server.transports for RPC HTTP: Tasks 1, 6.
  • server.sources tagged union with stdlib only: Task 1.
  • Store-backed source registry deferred: docs in Task 7.
  • No MCP CLI target: enforced by model union in Task 1.
  • Remote CLI subset only: Tasks 3-5.

Known risks:

  • wf_transport_rpc_http.client monkeypatching in CLI tests is awkward because Typer commands call asyncio.run. Keep this test narrow; do not overbuild a test framework in this slice.
  • wf_cli.context still keeps old MCP config compatibility. That dependency should shrink later, but removing it here would make the slice too broad.