feat: group draft add commands

This commit is contained in:
lda
2026-07-20 17:14:11 +07:00 Verified
parent 518def05f0
commit 63e7a17f9e
5 changed files with 350 additions and 164 deletions
+121
View File
@@ -0,0 +1,121 @@
from __future__ import annotations
from typing import Annotated
import typer
from wf_cli.context import load_cli_context_from_typer as load_cli_context
from wf_cli.io import emit_json
from wf_cli.remote_errors import run_cli_operation
from .draft_options import _parse_map_flags, _parse_output_map_flags, _parse_route_flags
app = typer.Typer(
name="add",
help="Add one typed step to a draft workspace.",
no_args_is_help=True,
)
@app.command("capability")
def add_step_from_capability(
ctx: typer.Context,
workspace_id: Annotated[str, typer.Argument(help="Draft workspace id.")],
revision: Annotated[
int, typer.Option("--revision", min=1, help="Expected workspace revision.")
],
step_id: Annotated[str, typer.Option("--step", help="New draft step id.")],
capability_name: Annotated[
str, typer.Option("--capability", help="Qualified capability name.")
],
route_from_step: Annotated[
str | None,
typer.Option(
"--from-step",
help="Optional existing step whose outcome should route to this step.",
),
] = None,
route_from_outcome: Annotated[
str,
typer.Option("--from-outcome", help="Outcome on --from-step."),
] = "ok",
route: Annotated[
list[str] | None,
typer.Option(
"--route",
help="Route mapping OUTCOME=TARGET. Repeat for multiple outcomes.",
),
] = None,
input_mapping: Annotated[
list[str] | None,
typer.Option(
"--input",
help=(
"Input binding SOURCE=LOCAL_TARGET. Repeat the flag for each "
"input; do not put multiple mappings after one --input."
),
),
] = None,
output_mapping: Annotated[
list[str] | None,
typer.Option(
"--bind-output",
help=(
"Output binding LOCAL_OUTPUT=STATE_TARGET with state schema "
"projection. Repeat the flag for each output; do not put "
"multiple mappings after one --bind-output."
),
),
] = None,
) -> None:
"""Add a capability step; this also projects its schemas and bindings.
This command does not guess missing maps. Pass the route and bindings you
want, then run `wf draft validate <workspace_id>`.
Repeat the flag for multiple bindings:
`--input state.title=title --input state.summary=summary`
`--bind-output title=state.title --bind-output summary=state.summary`
"""
input_map = _parse_map_flags(input_mapping)
bind_outputs = _parse_output_map_flags(output_mapping)
routes = _parse_route_flags(route)
context = load_cli_context(ctx)
emit_json(
run_cli_operation(
context,
context.handlers.add_step_from_capability(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
capability_name=capability_name,
route_from_step=route_from_step,
route_from_outcome=route_from_outcome,
routes=routes or None,
input_map=input_map,
bind_outputs=bind_outputs,
),
)
)
# These subgroups establish the public command boundary for later task slices.
# They intentionally have no command bodies until their typed options are ready.
for _name in (
"interrupt",
"foreach",
"join",
"end",
"when",
"choose",
"match",
"subgraph",
):
app.add_typer(
typer.Typer(
name=_name,
help=f"Add a {_name} step (available in a later CLI task).",
no_args_is_help=True,
),
name=_name,
)
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import typer
from wf_api.surface import RouteSource
from wf_core.paths import LocalPath, PathResolutionError, StatePath
def _parse_assignment_flags(
values: list[str] | None,
*,
option_name: str,
expected: str,
) -> dict[str, str]:
parsed: dict[str, str] = {}
for item in values or []:
source, separator, target = item.partition("=")
if separator != "=" or not source or not target:
raise typer.BadParameter(f"{option_name} must use {expected}")
if source in parsed:
raise typer.BadParameter(f"duplicate {option_name} for {source!r}")
parsed[source] = target
return parsed
def _parse_map_flags(values: list[str] | None) -> dict[str, str]:
return _parse_assignment_flags(
values,
option_name="--map",
expected="source=target",
)
def _parse_output_map_flags(values: list[str] | None) -> dict[str, str]:
parsed = _parse_assignment_flags(
values,
option_name="--bind-output",
expected="LOCAL_OUTPUT=STATE_TARGET",
)
for local_output, state_target in parsed.items():
try:
LocalPath.parse(local_output)
except PathResolutionError as exc:
raise typer.BadParameter(
f"--bind-output source {local_output!r} must be a node-local "
"output path such as value or ."
) from exc
try:
StatePath.parse(state_target)
except PathResolutionError as exc:
raise typer.BadParameter(
f"--bind-output target {state_target!r} must be a state path "
"such as state.value"
) from exc
return parsed
def _parse_step_input_map_flags(values: list[str] | None) -> dict[str, str]:
"""Parse graph-source to bare-local input mappings for one draft step."""
parsed = _parse_map_flags(values)
for source, target in parsed.items():
if target.startswith("local."):
bare_target = target.removeprefix("local.")
raise typer.BadParameter(
"--map target must be a bare local field; "
f"use {source}={bare_target}, not {source}={target}"
)
return parsed
def _parse_route_flags(values: list[str] | None) -> dict[str, str]:
return _parse_assignment_flags(
values,
option_name="--route",
expected="OUTCOME=TARGET",
)
def parse_json_file(path: Path, *, option_name: str) -> Any:
"""Read one structured CLI value and report file/JSON failures as option errors."""
try:
return json.loads(path.read_text(encoding="utf-8"))
except OSError as exc:
raise typer.BadParameter(f"{option_name}: cannot read {path}: {exc}") from exc
except json.JSONDecodeError as exc:
raise typer.BadParameter(
f"{option_name}: invalid JSON in {path}: {exc.msg}"
) from exc
def route_source(from_step: str | None, from_outcome: str | None) -> RouteSource | None:
if from_step is None:
if from_outcome is not None:
raise typer.BadParameter("--from-outcome requires --from-step")
return None
return RouteSource(step_id=from_step, outcome=from_outcome or "ok")
+7 -154
View File
@@ -6,88 +6,23 @@ from typing import Annotated, Literal
import typer
from wf_cli.commands import draft_add
from wf_cli.commands.draft_options import (
_parse_map_flags,
_parse_route_flags,
_parse_step_input_map_flags,
)
from wf_cli.context import load_cli_context_from_typer as load_cli_context
from wf_cli.formats import ListOutputFormat, emit_list_payload
from wf_cli.io import CliInputError, emit_json, parse_bindings, parse_json_value
from wf_cli.remote_errors import run_cli_operation
from wf_core.paths import LocalPath, PathResolutionError, StatePath
def _parse_assignment_flags(
values: list[str] | None,
*,
option_name: str,
expected: str,
) -> dict[str, str]:
parsed: dict[str, str] = {}
for item in values or []:
source, separator, target = item.partition("=")
if separator != "=" or not source or not target:
raise typer.BadParameter(f"{option_name} must use {expected}")
if source in parsed:
raise typer.BadParameter(f"duplicate {option_name} for {source!r}")
parsed[source] = target
return parsed
def _parse_map_flags(values: list[str] | None) -> dict[str, str]:
return _parse_assignment_flags(
values,
option_name="--map",
expected="source=target",
)
def _parse_output_map_flags(values: list[str] | None) -> dict[str, str]:
parsed = _parse_assignment_flags(
values,
option_name="--bind-output",
expected="LOCAL_OUTPUT=STATE_TARGET",
)
for local_output, state_target in parsed.items():
try:
LocalPath.parse(local_output)
except PathResolutionError as exc:
raise typer.BadParameter(
f"--bind-output source {local_output!r} must be a node-local "
"output path such as value or ."
) from exc
try:
StatePath.parse(state_target)
except PathResolutionError as exc:
raise typer.BadParameter(
f"--bind-output target {state_target!r} must be a state path "
"such as state.value"
) from exc
return parsed
def _parse_step_input_map_flags(values: list[str] | None) -> dict[str, str]:
"""Parse graph-source to bare-local input mappings for one draft step."""
parsed = _parse_map_flags(values)
for source, target in parsed.items():
if target.startswith("local."):
bare_target = target.removeprefix("local.")
raise typer.BadParameter(
"--map target must be a bare local field; "
f"use {source}={bare_target}, not {source}={target}"
)
return parsed
def _parse_route_flags(values: list[str] | None) -> dict[str, str]:
return _parse_assignment_flags(
values,
option_name="--route",
expected="OUTCOME=TARGET",
)
app = typer.Typer(
name="draft",
help="Create, inspect, patch, validate, and save draft workflows.",
no_args_is_help=True,
)
app.add_typer(draft_add.app, name="add")
@app.command("list")
@@ -443,88 +378,6 @@ def bind_draft(
)
@app.command("add-step")
def add_step_from_capability(
ctx: typer.Context,
workspace_id: Annotated[str, typer.Argument(help="Draft workspace id.")],
revision: Annotated[
int, typer.Option("--revision", min=1, help="Expected workspace revision.")
],
step_id: Annotated[str, typer.Option("--step", help="New draft step id.")],
capability_name: Annotated[
str, typer.Option("--capability", help="Qualified capability name.")
],
route_from_step: Annotated[
str | None,
typer.Option(
"--from-step",
help="Optional existing step whose outcome should route to this step.",
),
] = None,
route_from_outcome: Annotated[
str,
typer.Option("--from-outcome", help="Outcome on --from-step."),
] = "ok",
route: Annotated[
list[str] | None,
typer.Option(
"--route",
help="Route mapping OUTCOME=TARGET. Repeat for multiple outcomes.",
),
] = None,
input_mapping: Annotated[
list[str] | None,
typer.Option(
"--input",
help=(
"Input binding SOURCE=LOCAL_TARGET. Repeat the flag for each "
"input; do not put multiple mappings after one --input."
),
),
] = None,
output_mapping: Annotated[
list[str] | None,
typer.Option(
"--bind-output",
help=(
"Output binding LOCAL_OUTPUT=STATE_TARGET with state schema "
"projection. Repeat the flag for each output; do not put "
"multiple mappings after one --bind-output."
),
),
] = None,
) -> None:
"""Add one capability-backed step with explicit route, input, and output wiring.
This command does not guess missing maps. Pass the route and bindings you
want, then run `wf draft validate <workspace_id>`.
Repeat the flag for multiple bindings:
`--input state.title=title --input state.summary=summary`
`--bind-output title=state.title --bind-output summary=state.summary`
"""
input_map = _parse_map_flags(input_mapping)
bind_outputs = _parse_output_map_flags(output_mapping)
routes = _parse_route_flags(route)
context = load_cli_context(ctx)
emit_json(
run_cli_operation(
context,
context.handlers.add_step_from_capability(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
capability_name=capability_name,
route_from_step=route_from_step,
route_from_outcome=route_from_outcome,
routes=routes or None,
input_map=input_map,
bind_outputs=bind_outputs,
),
)
)
@app.command("branch")
def branch_draft(
ctx: typer.Context,