Files
lda-wf/docs/historical/superpowers/plans/2026-06-01-wf-cli-run-deploy.md
T

17 KiB

wf CLI Run And Deploy 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.

Goal: Implement the first useful wf CLI vertical slice: wf deploy validate, wf run start, wf run inspect, and wf run trace.

Architecture: Keep command modules thin Typer wrappers over WorkflowSurfaceHandlers loaded through wf_cli.context. Use real file-backed stores in tests so CLI behavior matches the MCP workflow surface. Keep JSON output as the default and reuse wf_cli.io for payload parsing/printing.

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


File Structure

  • Modify src/wf_cli/app.py

    • Store root --config in Typer context so subcommands can load the requested config.
  • Modify src/wf_cli/context.py

    • Add config_path_from_context(ctx) helper.
  • Modify src/wf_cli/commands/deployments.py

    • Add validate command.
  • Modify src/wf_cli/commands/runs.py

    • Add start, inspect, and trace commands.
  • Create tests/wf_cli/test_run_deploy.py

    • End-to-end CLI tests using CliRunner.
    • Seed artifact/deployment data into the configured store.

Scope Boundaries

  • Do not implement wf deploy save, wf deploy delete, or wf deploy inspect.
  • Do not implement wf run resume.
  • Do not implement wf cap, wf draft, wf explain, or wf schema.
  • Do not add table/ids/compact formatting.
  • Do not add stdin input yet; use --input and --input-file.
  • Do not mock WorkflowSurfaceHandlers; use the real service/store path.

Task 1: Make Global --config Available To Commands

Files:

  • Modify: src/wf_cli/app.py

  • Modify: src/wf_cli/context.py

  • Modify: tests/wf_cli/test_app.py

  • Step 1: Add a failing test for config propagation

Append to tests/wf_cli/test_app.py:

def test_root_callback_stores_config_path() -> None:
    result = runner.invoke(app, ["--config", "custom.json", "run", "--help"])

    assert result.exit_code == 0
    assert "Run workflow deployments" in result.output

This mostly locks down that root --config remains accepted before subcommands.

  • Step 2: Update root callback to use Typer context

In src/wf_cli/app.py, change the callback signature to:

@app.callback()
def root(
    ctx: typer.Context,
    config: Annotated[
        str,
        typer.Option(
            "--config",
            help="Path to workflow/MCP config JSON.",
        ),
    ] = "wf_mcp.config.json",
) -> None:
    """Run workflow platform commands."""
    ctx.obj = {"config_path": config}

Keep import typer already present.

  • Step 3: Add config helper

In src/wf_cli/context.py, add:

import typer

Then add below CliContext:

def config_path_from_context(ctx: typer.Context) -> str:
    """Return the root --config path captured by the Typer callback."""
    obj = ctx.obj if isinstance(ctx.obj, dict) else {}
    value = obj.get("config_path", "wf_mcp.config.json")
    return value if isinstance(value, str) else "wf_mcp.config.json"
  • Step 4: Run app/context tests

Run:

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

Expected: PASS.


Task 2: Add CLI Run/Deploy Test Fixture Helpers

Files:

  • Create: tests/wf_cli/test_run_deploy.py

  • Step 1: Create fixture helpers

Create tests/wf_cli/test_run_deploy.py with this opening:

from __future__ import annotations

import json
from pathlib import Path

from typer.testing import CliRunner

from wf_artifacts import FileWorkflowArtifactStore, WorkflowDeployment
from wf_cli.app import app
from wf_mcp.models import ConnectionConfig

from tests.wf_mcp.test_support import echo_tool, local_temp_root
from tests.wf_mcp.workflow_surface.conftest import echo_artifact


runner = CliRunner()


def _write_config(root: Path) -> Path:
    config_path = root / "wf_mcp.config.json"
    config_path.write_text(
        json.dumps(
            {
                "store_root": ".wf_mcp_store",
                "connections": [
                    {
                        "id": "demo.personal",
                        "server": "demo",
                        "account": "personal",
                    }
                ],
            }
        ),
        encoding="utf-8",
    )
    return config_path


def _seed_echo_deployment(root: Path) -> Path:
    config_path = _write_config(root)
    store_root = root / ".wf_mcp_store"
    artifact_store = FileWorkflowArtifactStore(store_root)
    artifact_store.save_artifact(echo_artifact())
    artifact_store.save_deployment(
        WorkflowDeployment(
            id="echo.personal",
            artifact_id="echo",
            artifact_version=1,
            bindings=[{"logical_source": "demo", "concrete_source": "demo.personal"}],
        )
    )
    return config_path

This fixture writes the same config/store shape that wf_cli.context loads.

  • Step 2: Add failing deploy validate test

Append:

def test_wf_deploy_validate_outputs_json() -> None:
    root = local_temp_root() / "wf_cli_deploy_validate"
    root.mkdir(parents=True, exist_ok=True)
    config_path = _seed_echo_deployment(root)

    result = runner.invoke(
        app,
        ["--config", str(config_path), "deploy", "validate", "echo.personal"],
    )

    assert result.exit_code == 0
    payload = json.loads(result.output)
    assert payload["deployment_id"] == "echo.personal"
    assert payload["status"] == "runnable"
    assert payload["next_actions"]["recommended_next_tool"] == (
        "wf.workflow.run_deployment"
    )
  • Step 3: Run test to verify it fails

Run:

uv run pytest tests/wf_cli/test_run_deploy.py::test_wf_deploy_validate_outputs_json -q

Expected: FAIL because deploy validate does not exist.


Task 3: Implement wf deploy validate

Files:

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

  • Step 1: Add imports and command

Replace src/wf_cli/commands/deployments.py with:

from __future__ import annotations

import asyncio
from typing import Annotated

import typer

from wf_cli.context import config_path_from_context, load_cli_context
from wf_cli.io import emit_json

app = typer.Typer(
    name="deploy",
    help="Save, inspect, validate, and delete workflow deployments.",
    no_args_is_help=True,
)


@app.command("validate")
def validate_deployment(
    ctx: typer.Context,
    deployment_id: Annotated[str, typer.Argument(help="Deployment id to validate.")],
    live: Annotated[
        bool,
        typer.Option(
            "--live",
            help="Also perform opt-in upstream liveness checks.",
        ),
    ] = False,
) -> None:
    """Validate one saved workflow deployment."""
    context = load_cli_context(config_path_from_context(ctx))
    payload = asyncio.run(
        context.handlers.validate_deployment(
            deployment_id=deployment_id,
            live_check=live,
        )
    )
    emit_json(payload)
  • Step 2: Run deploy test

Run:

uv run pytest tests/wf_cli/test_run_deploy.py::test_wf_deploy_validate_outputs_json -q

Expected: PASS.


Task 4: Add Run Command Tests

Files:

  • Modify: tests/wf_cli/test_run_deploy.py

  • Step 1: Add run start test

Append:

def test_wf_run_start_accepts_inline_json_input() -> None:
    root = local_temp_root() / "wf_cli_run_start"
    root.mkdir(parents=True, exist_ok=True)
    config_path = _seed_echo_deployment(root)

    result = runner.invoke(
        app,
        [
            "--config",
            str(config_path),
            "run",
            "start",
            "echo.personal",
            "--input",
            '{"text": "hello"}',
        ],
    )

    assert result.exit_code == 0
    payload = json.loads(result.output)
    assert payload["status"] == "completed"
    assert payload["output"]["echoed"] == "hello"
    assert isinstance(payload["run_id"], str)
    assert payload["next_actions"]["can_continue"] is False
  • Step 2: Add run start file input test

Append:

def test_wf_run_start_accepts_input_file() -> None:
    root = local_temp_root() / "wf_cli_run_start_file"
    root.mkdir(parents=True, exist_ok=True)
    config_path = _seed_echo_deployment(root)
    input_path = root / "input.json"
    input_path.write_text('{"text": "from file"}', encoding="utf-8")

    result = runner.invoke(
        app,
        [
            "--config",
            str(config_path),
            "run",
            "start",
            "echo.personal",
            "--input-file",
            str(input_path),
        ],
    )

    assert result.exit_code == 0
    payload = json.loads(result.output)
    assert payload["status"] == "completed"
    assert payload["output"]["echoed"] == "from file"
  • Step 3: Add inspect and trace test

Append:

def test_wf_run_inspect_and_trace_existing_run() -> None:
    root = local_temp_root() / "wf_cli_run_inspect_trace"
    root.mkdir(parents=True, exist_ok=True)
    config_path = _seed_echo_deployment(root)
    start = runner.invoke(
        app,
        [
            "--config",
            str(config_path),
            "run",
            "start",
            "echo.personal",
            "--input",
            '{"text": "hello"}',
        ],
    )
    run_id = json.loads(start.output)["run_id"]

    inspected = runner.invoke(
        app,
        ["--config", str(config_path), "run", "inspect", run_id],
    )
    traced = runner.invoke(
        app,
        [
            "--config",
            str(config_path),
            "run",
            "trace",
            run_id,
            "--from",
            "0",
            "--limit",
            "1",
        ],
    )

    assert inspected.exit_code == 0
    inspected_payload = json.loads(inspected.output)
    assert inspected_payload["run_id"] == run_id
    assert inspected_payload["status"] == "completed"
    assert "trace" not in inspected_payload

    assert traced.exit_code == 0
    traced_payload = json.loads(traced.output)
    assert traced_payload["run_id"] == run_id
    assert traced_payload["trace_start"] == 0
    assert traced_payload["trace_limit"] == 1
    assert traced_payload["trace"][0]["node_id"] == "echo"
  • Step 4: Run tests to verify they fail

Run:

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

Expected: deploy test passes, run tests fail because run commands do not exist.


Task 5: Implement Run Commands

Files:

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

  • Step 1: Replace run command module

Replace src/wf_cli/commands/runs.py with:

from __future__ import annotations

import asyncio
from pathlib import Path
from typing import Annotated

import typer

from wf_cli.context import config_path_from_context, load_cli_context
from wf_cli.io import CliInputError, emit_json, parse_json_input
from wf_mcp.workflow_surface import TraceRange

app = typer.Typer(
    name="run",
    help="Run workflow deployments and inspect durable runs.",
    no_args_is_help=True,
)


@app.command("start")
def start_run(
    ctx: typer.Context,
    deployment_id: Annotated[str, typer.Argument(help="Deployment id to run.")],
    input_json: Annotated[
        str | None,
        typer.Option("--input", help="Workflow input JSON object."),
    ] = None,
    input_file: Annotated[
        Path | None,
        typer.Option("--input-file", help="Path to workflow input JSON object."),
    ] = None,
    trace_from: Annotated[
        int | None,
        typer.Option("--trace-from", min=0, help="Optional trace slice start."),
    ] = None,
    trace_limit: Annotated[
        int | None,
        typer.Option("--trace-limit", min=1, max=100, help="Optional trace slice limit."),
    ] = None,
) -> None:
    """Start one workflow deployment."""
    try:
        workflow_input = parse_json_input(input_json=input_json, input_file=input_file)
    except CliInputError as exc:
        raise typer.BadParameter(str(exc)) from exc
    context = load_cli_context(config_path_from_context(ctx))
    trace_range = _optional_trace_range(start=trace_from, limit=trace_limit)
    payload = asyncio.run(
        context.handlers.run_deployment(
            deployment_id=deployment_id,
            workflow_input=workflow_input,
            trace_range=trace_range,
        )
    )
    emit_json(payload)


@app.command("inspect")
def inspect_run(
    ctx: typer.Context,
    run_id: Annotated[str, typer.Argument(help="Durable run id to inspect.")],
) -> None:
    """Inspect a durable run without trace entries."""
    context = load_cli_context(config_path_from_context(ctx))
    emit_json(asyncio.run(context.handlers.inspect_run(run_id=run_id)))


@app.command("trace")
def trace_run(
    ctx: typer.Context,
    run_id: Annotated[str, typer.Argument(help="Durable run id to trace.")],
    trace_from: Annotated[
        int,
        typer.Option("--from", min=0, help="Zero-based trace start offset."),
    ] = 0,
    limit: Annotated[
        int,
        typer.Option("--limit", min=1, max=100, help="Maximum trace entries."),
    ] = 25,
) -> None:
    """Read a bounded debug trace slice."""
    context = load_cli_context(config_path_from_context(ctx))
    payload = asyncio.run(
        context.handlers.read_run_trace(
            run_id=run_id,
            trace_range=TraceRange(start=trace_from, limit=limit),
        )
    )
    emit_json(payload)


def _optional_trace_range(*, start: int | None, limit: int | None) -> TraceRange | None:
    """Build a trace range only when the caller requested trace detail."""
    if start is None and limit is None:
        return None
    return TraceRange(start=start or 0, limit=limit or 25)
  • Step 2: Run CLI run/deploy tests

Run:

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

Expected: PASS.


Task 6: Verify Help And Input Error Behavior

Files:

  • Modify: tests/wf_cli/test_app.py

  • Modify: tests/wf_cli/test_run_deploy.py

  • Step 1: Add help assertions

Append to tests/wf_cli/test_app.py:

def test_wf_deploy_validate_help_exists() -> None:
    result = runner.invoke(app, ["deploy", "validate", "--help"])

    assert result.exit_code == 0
    assert "Validate one saved workflow deployment" in result.output


def test_wf_run_start_help_exists() -> None:
    result = runner.invoke(app, ["run", "start", "--help"])

    assert result.exit_code == 0
    assert "--input-file" in result.output
  • Step 2: Add bad JSON assertion

Append to tests/wf_cli/test_run_deploy.py:

def test_wf_run_start_reports_bad_json() -> None:
    root = local_temp_root() / "wf_cli_run_bad_json"
    root.mkdir(parents=True, exist_ok=True)
    config_path = _seed_echo_deployment(root)

    result = runner.invoke(
        app,
        [
            "--config",
            str(config_path),
            "run",
            "start",
            "echo.personal",
            "--input",
            "{",
        ],
    )

    assert result.exit_code != 0
    assert "invalid JSON" in result.output
  • Step 3: Run tests

Run:

uv run pytest tests/wf_cli/test_app.py tests/wf_cli/test_run_deploy.py -q

Expected: PASS.


Task 7: Verification

Files:

  • All touched files.

  • Step 1: Run all CLI tests

Run:

uv run pytest tests/wf_cli -q

Expected: PASS.

  • Step 2: Run focused workflow surface tests

Run:

uv run pytest tests/wf_mcp/workflow_surface/test_deployments.py tests/wf_mcp/workflow_surface/test_runs.py -q

Expected: PASS.

  • Step 3: Run lint

Run:

uv run ruff check src/wf_cli tests/wf_cli

Expected: PASS.

  • Step 4: Run format check

Run:

uv run ruff format --check src/wf_cli tests/wf_cli

Expected: PASS.

  • Step 5: Run type check

Run:

uv run basedpyright --level error

Expected: 0 errors.


Self-Review Checklist

  • wf deploy validate returns handler JSON with next_actions.
  • wf run start accepts --input and --input-file.
  • wf run inspect omits trace entries.
  • wf run trace requires bounded --from / --limit.
  • Commands load the root --config path.
  • Command modules stay thin and do not duplicate workflow logic.
  • No unrelated CLI groups or draft authoring commands were implemented.

Notes For Opencode

  • Keep this to run/deploy commands only.
  • Do not implement wf run resume in this slice.
  • Do not add --format; JSON stays default.
  • Use real file-backed stores in tests.
  • If Typer callback context is annoying, keep the smallest helper in wf_cli.context rather than passing global state.