and wf_cli is usable

This commit is contained in:
lda
2026-06-01 04:43:53 +07:00 Verified
parent a15b1035d3
commit 8740b495b9
9 changed files with 2072 additions and 8 deletions
File diff suppressed because it is too large Load Diff
+61
View File
@@ -1,9 +1,70 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from typing import Annotated, Literal
import typer import typer
from wf_cli.context import config_path_from_context, load_cli_context
from wf_cli.formats import ListOutputFormat, emit_list_payload
from wf_cli.io import emit_json
app = typer.Typer( app = typer.Typer(
name="artifact", name="artifact",
help="List and inspect saved workflow artifacts.", help="List and inspect saved workflow artifacts.",
no_args_is_help=True, no_args_is_help=True,
) )
@app.command("list")
def list_artifacts(
ctx: typer.Context,
query: Annotated[
str | None, typer.Option("--query", help="Search artifact summaries.")
] = None,
kind: Annotated[
Literal["workflow", "wrapper"] | None,
typer.Option("--kind", help="Filter artifact kind."),
] = None,
cursor: Annotated[
str | None, typer.Option("--cursor", help="Pagination cursor.")
] = None,
limit: Annotated[
int, typer.Option("--limit", min=1, max=100, help="Maximum rows.")
] = 50,
output_format: Annotated[
ListOutputFormat, typer.Option("--format", help="Output format.")
] = ListOutputFormat.JSON,
) -> None:
"""List compact saved artifact summaries."""
context = load_cli_context(config_path_from_context(ctx))
payload = asyncio.run(
context.handlers.list_artifacts(
query=query,
kind=kind,
cursor=cursor,
limit=limit,
)
)
emit_list_payload(
payload,
collection_key="nodes",
output_format=output_format,
id_field="name",
summary_fields=("kind", "display_name", "description"),
)
@app.command("inspect")
def inspect_artifact(
ctx: typer.Context,
artifact_id: Annotated[str, typer.Argument(help="Artifact id.")],
version: Annotated[int, typer.Argument(min=1, help="Artifact version.")],
) -> None:
"""Inspect one saved artifact version."""
context = load_cli_context(config_path_from_context(ctx))
emit_json(
asyncio.run(
context.handlers.inspect_artifact(artifact_id=artifact_id, version=version)
)
)
+59
View File
@@ -1,9 +1,68 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from typing import Annotated
import typer import typer
from wf_cli.context import config_path_from_context, load_cli_context
from wf_cli.formats import ListOutputFormat, emit_list_payload
from wf_cli.io import emit_json
app = typer.Typer( app = typer.Typer(
name="cap", name="cap",
help="Inspect and call workflow capabilities.", help="Inspect and call workflow capabilities.",
no_args_is_help=True, no_args_is_help=True,
) )
@app.command("list")
def list_capabilities(
ctx: typer.Context,
query: Annotated[
str | None,
typer.Option("--query", help="Search capability names/descriptions."),
] = None,
source_id: Annotated[
str | None, typer.Option("--source", help="Filter by source id.")
] = None,
cursor: Annotated[
str | None, typer.Option("--cursor", help="Pagination cursor.")
] = None,
limit: Annotated[
int, typer.Option("--limit", min=1, max=100, help="Maximum rows.")
] = 50,
output_format: Annotated[
ListOutputFormat, typer.Option("--format", help="Output format.")
] = ListOutputFormat.JSON,
) -> None:
"""List compact planner-visible workflow capabilities."""
context = load_cli_context(config_path_from_context(ctx))
payload = asyncio.run(
context.handlers.list_capabilities(
query=query,
source_id=source_id,
cursor=cursor,
limit=limit,
)
)
emit_list_payload(
payload,
collection_key="capabilities",
output_format=output_format,
id_field="name",
summary_fields=("source_id", "kind", "description"),
)
@app.command("inspect")
def inspect_capability(
ctx: typer.Context,
qualified_name: Annotated[str, typer.Argument(help="Workflow capability name.")],
) -> None:
"""Inspect one workflow capability contract."""
context = load_cli_context(config_path_from_context(ctx))
payload = asyncio.run(
context.handlers.inspect_capability(qualified_name=qualified_name)
)
emit_json(payload)
+105 -1
View File
@@ -1,12 +1,14 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from pathlib import Path
from typing import Annotated from typing import Annotated
import typer import typer
from wf_cli.context import config_path_from_context, load_cli_context from wf_cli.context import config_path_from_context, load_cli_context
from wf_cli.io import emit_json from wf_cli.formats import ListOutputFormat, emit_list_payload
from wf_cli.io import CliInputError, emit_json, parse_bindings, parse_json_input
app = typer.Typer( app = typer.Typer(
name="deploy", name="deploy",
@@ -36,3 +38,105 @@ def validate_deployment(
) )
) )
emit_json(payload) emit_json(payload)
@app.command("list")
def list_deployments(
ctx: typer.Context,
output_format: Annotated[
ListOutputFormat, typer.Option("--format", help="Output format.")
] = ListOutputFormat.JSON,
) -> None:
"""List saved workflow deployments."""
context = load_cli_context(config_path_from_context(ctx))
payload = asyncio.run(context.handlers.list_deployments())
emit_list_payload(
payload,
collection_key="deployments",
output_format=output_format,
id_field="id",
summary_fields=("artifact_id", "artifact_version", "drift_policy"),
)
@app.command("inspect")
def inspect_deployment(
ctx: typer.Context,
deployment_id: Annotated[str, typer.Argument(help="Deployment id.")],
) -> None:
"""Inspect one saved deployment."""
context = load_cli_context(config_path_from_context(ctx))
emit_json(
asyncio.run(context.handlers.inspect_deployment(deployment_id=deployment_id))
)
@app.command("save")
def save_deployment(
ctx: typer.Context,
deployment_id: Annotated[str | None, typer.Argument(help="Deployment id.")] = None,
artifact_id: Annotated[
str | None, typer.Option("--artifact", help="Artifact id.")
] = None,
version: Annotated[
int | None, typer.Option("--version", min=1, help="Artifact version.")
] = None,
binding: Annotated[
list[str] | None,
typer.Option("--binding", help="Logical=concrete source binding. Repeatable."),
] = None,
input_json: Annotated[
str | None, typer.Option("--input", help="Full deployment JSON object.")
] = None,
input_file: Annotated[
Path | None,
typer.Option("--input-file", help="Path to full deployment JSON object."),
] = None,
) -> None:
"""Save a workflow deployment from flags or a JSON object."""
try:
if input_json is not None or input_file is not None:
payload = parse_json_input(input_json=input_json, input_file=input_file)
else:
payload = _deployment_payload_from_flags(
deployment_id=deployment_id,
artifact_id=artifact_id,
version=version,
bindings=binding or [],
)
except CliInputError as exc:
raise typer.BadParameter(str(exc)) from exc
context = load_cli_context(config_path_from_context(ctx))
emit_json(asyncio.run(context.handlers.save_deployment(payload)))
@app.command("delete")
def delete_deployment(
ctx: typer.Context,
deployment_id: Annotated[str, typer.Argument(help="Deployment id.")],
) -> None:
"""Delete one saved deployment."""
context = load_cli_context(config_path_from_context(ctx))
emit_json(
asyncio.run(context.handlers.delete_deployment(deployment_id=deployment_id))
)
def _deployment_payload_from_flags(
*,
deployment_id: str | None,
artifact_id: str | None,
version: int | None,
bindings: list[str],
) -> dict[str, object]:
"""Build deployment JSON from ergonomic flags without hiding the model shape."""
if deployment_id is None or artifact_id is None or version is None:
raise CliInputError(
"deployment_id, --artifact, and --version are required without --input"
)
return {
"deployment_id": deployment_id,
"artifact_id": artifact_id,
"artifact_version": version,
"bindings": parse_bindings(bindings),
}
+176
View File
@@ -1,9 +1,185 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Annotated, Literal
import typer import typer
from wf_cli.context import config_path_from_context, 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
app = typer.Typer( app = typer.Typer(
name="draft", name="draft",
help="Create, inspect, patch, validate, and save draft workflows.", help="Create, inspect, patch, validate, and save draft workflows.",
no_args_is_help=True, no_args_is_help=True,
) )
@app.command("list")
def list_drafts(
ctx: typer.Context,
output_format: Annotated[
ListOutputFormat, typer.Option("--format", help="Output format.")
] = ListOutputFormat.JSON,
) -> None:
"""List stored draft workspaces."""
context = load_cli_context(config_path_from_context(ctx))
payload = asyncio.run(context.handlers.list_draft_workspaces())
emit_list_payload(
payload,
collection_key="workspaces",
output_format=output_format,
id_field="workspace_id",
summary_fields=("title", "revision", "status"),
)
@app.command("inspect")
def inspect_draft(
ctx: typer.Context,
workspace_id: Annotated[str, typer.Argument(help="Draft workspace id.")],
include_draft: Annotated[
bool, typer.Option("--include-draft", help="Include full draft JSON.")
] = False,
) -> None:
"""Inspect one draft workspace."""
context = load_cli_context(config_path_from_context(ctx))
emit_json(
asyncio.run(
context.handlers.get_draft_workspace(
workspace_id=workspace_id,
include_draft=include_draft,
)
)
)
@app.command("create-from-capability")
def create_from_capability(
ctx: typer.Context,
workspace_id: Annotated[str, typer.Argument(help="Draft workspace id.")],
capability_name: Annotated[str, typer.Argument(help="Workflow capability name.")],
name: Annotated[
str | None, typer.Option("--name", help="Draft workflow name.")
] = None,
title: Annotated[
str | None, typer.Option("--title", help="Workspace title.")
] = None,
) -> None:
"""Bootstrap a draft workspace from inspect_capability wrapper hints."""
context = load_cli_context(config_path_from_context(ctx))
emit_json(
asyncio.run(
context.handlers.create_draft_workspace_from_capability(
workspace_id=workspace_id,
capability_name=capability_name,
name=name,
title=title,
)
)
)
@app.command("patch")
def patch_draft(
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.")
],
input_json: Annotated[
str | None, typer.Option("--input", help="JSON Patch array.")
] = None,
input_file: Annotated[
Path | None, typer.Option("--input-file", help="Path to JSON Patch array.")
] = None,
) -> None:
"""Apply an RFC 6902 JSON Patch to a draft workspace."""
try:
patch = parse_json_value(input_json=input_json, input_file=input_file)
except CliInputError as exc:
raise typer.BadParameter(str(exc)) from exc
if not isinstance(patch, list):
raise typer.BadParameter("draft patch input must be a JSON array")
context = load_cli_context(config_path_from_context(ctx))
emit_json(
asyncio.run(
context.handlers.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=patch,
)
)
)
@app.command("validate")
def validate_draft(
ctx: typer.Context,
workspace_id: Annotated[str, typer.Argument(help="Draft workspace id.")],
) -> None:
"""Validate one stored draft workspace."""
context = load_cli_context(config_path_from_context(ctx))
emit_json(
asyncio.run(
context.handlers.validate_draft_workspace(workspace_id=workspace_id)
)
)
@app.command("save")
def save_draft(
ctx: typer.Context,
workspace_id: Annotated[str, typer.Argument(help="Draft workspace id.")],
artifact_id: Annotated[str, typer.Option("--artifact", help="Artifact id.")],
version: Annotated[int, typer.Option("--version", min=1, help="Artifact version.")],
title: Annotated[str, typer.Option("--title", help="Artifact title.")],
outcome: Annotated[
list[str] | None,
typer.Option("--outcome", help="Artifact outcome. Repeatable."),
] = None,
kind: Annotated[
Literal["workflow", "wrapper"], typer.Option("--kind", help="Artifact kind.")
] = "workflow",
description: Annotated[
str | None, typer.Option("--description", help="Artifact description.")
] = None,
binding: Annotated[
list[str] | None,
typer.Option("--binding", help="Logical=concrete source binding. Repeatable."),
] = None,
) -> None:
"""Save a validated draft workspace as a workflow or wrapper artifact."""
try:
source_bindings = parse_bindings(binding or [])
except CliInputError as exc:
raise typer.BadParameter(str(exc)) from exc
context = load_cli_context(config_path_from_context(ctx))
if kind == "wrapper":
payload = asyncio.run(
context.handlers.create_wrapper_from_workspace(
workspace_id=workspace_id,
artifact_id=artifact_id,
version=version,
title=title,
outcomes=tuple(outcome or ["ok"]),
description=description,
source_bindings=source_bindings or None,
)
)
else:
payload = asyncio.run(
context.handlers.create_artifact_from_workspace(
workspace_id=workspace_id,
artifact_id=artifact_id,
version=version,
title=title,
outcomes=tuple(outcome or ["ok"]),
kind="workflow",
description=description,
source_bindings=source_bindings or None,
)
)
emit_json(payload)
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
import json
from enum import StrEnum
from typing import Any
class ListOutputFormat(StrEnum):
"""Output formats allowed for list/discovery commands."""
JSON = "json"
IDS = "ids"
COMPACT = "compact"
def render_list_payload(
payload: dict[str, Any],
*,
collection_key: str,
output_format: ListOutputFormat,
id_field: str,
summary_fields: tuple[str, ...] = (),
) -> str:
"""Render a handler list payload without changing the JSON contract."""
if output_format is ListOutputFormat.JSON:
return json.dumps(payload, indent=2, sort_keys=True)
items = payload.get(collection_key, [])
if not isinstance(items, list):
raise ValueError(f"list payload missing array field {collection_key!r}")
if output_format is ListOutputFormat.IDS:
return "\n".join(_item_id(item, id_field=id_field) for item in items)
return "\n".join(
_compact_line(item, id_field=id_field, summary_fields=summary_fields)
for item in items
)
def emit_list_payload(
payload: dict[str, Any],
*,
collection_key: str,
output_format: ListOutputFormat,
id_field: str,
summary_fields: tuple[str, ...] = (),
) -> None:
"""Print a list payload in the requested CLI list format."""
print(
render_list_payload(
payload,
collection_key=collection_key,
output_format=output_format,
id_field=id_field,
summary_fields=summary_fields,
)
)
def _item_id(item: object, *, id_field: str) -> str:
if not isinstance(item, dict):
return str(item)
value = item.get(id_field)
return "" if value is None else str(value)
def _compact_line(
item: object,
*,
id_field: str,
summary_fields: tuple[str, ...],
) -> str:
if not isinstance(item, dict):
return str(item)
parts = [_item_id(item, id_field=id_field)]
for field in summary_fields:
if field in item and item[field] is not None:
parts.append(f"{field}={item[field]}")
return "\t".join(parts)
+29 -7
View File
@@ -9,21 +9,32 @@ class CliInputError(ValueError):
"""Raised when CLI JSON/file input cannot be parsed safely.""" """Raised when CLI JSON/file input cannot be parsed safely."""
def parse_json_value(
*,
input_json: str | None,
input_file: Path | None,
) -> Any:
"""Parse exactly one JSON value from inline JSON or a file path."""
if input_json is not None and input_file is not None:
raise CliInputError("--input and --input-file are mutually exclusive")
if input_json is None and input_file is None:
raise CliInputError("--input or --input-file is required")
raw = input_json if input_json is not None else _read_input_file(input_file)
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
raise CliInputError(f"invalid JSON input: {exc.msg}") from exc
def parse_json_input( def parse_json_input(
*, *,
input_json: str | None, input_json: str | None,
input_file: Path | None, input_file: Path | None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Parse exactly one JSON object from inline JSON or a file path.""" """Parse exactly one JSON object from inline JSON or a file path."""
if input_json is not None and input_file is not None:
raise CliInputError("--input and --input-file are mutually exclusive")
if input_json is None and input_file is None: if input_json is None and input_file is None:
return {} return {}
raw = input_json if input_json is not None else _read_input_file(input_file) payload = parse_json_value(input_json=input_json, input_file=input_file)
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise CliInputError(f"invalid JSON input: {exc.msg}") from exc
if not isinstance(payload, dict): if not isinstance(payload, dict):
raise CliInputError("JSON input must be an object") raise CliInputError("JSON input must be an object")
return payload return payload
@@ -34,6 +45,17 @@ def emit_json(payload: Any) -> None:
print(json.dumps(payload, indent=2, sort_keys=True)) print(json.dumps(payload, indent=2, sort_keys=True))
def parse_bindings(bindings: list[str]) -> dict[str, str]:
"""Parse repeatable logical=concrete source binding flags."""
parsed: dict[str, str] = {}
for item in bindings:
logical, separator, concrete = item.partition("=")
if separator != "=" or not logical or not concrete:
raise CliInputError("--binding must use logical=concrete")
parsed[logical] = concrete
return parsed
def _read_input_file(path: Path | None) -> str: def _read_input_file(path: Path | None) -> str:
"""Read a required JSON input file.""" """Read a required JSON input file."""
if path is None: if path is None:
+29
View File
@@ -57,3 +57,32 @@ def test_wf_explain_help_shows_input_modes() -> None:
assert "--input-file" in result.output assert "--input-file" in result.output
assert "--stdin" in result.output assert "--stdin" in result.output
assert "--list" in result.output assert "--list" in result.output
def test_wf_cap_list_help_exists() -> None:
result = runner.invoke(app, ["cap", "list", "--help"])
assert result.exit_code == 0
assert "--format" in result.output
assert "--source" in result.output
def test_wf_artifact_list_help_exists() -> None:
result = runner.invoke(app, ["artifact", "list", "--help"])
assert result.exit_code == 0
assert "--format" in result.output
def test_wf_deploy_save_help_exists() -> None:
result = runner.invoke(app, ["deploy", "save", "--help"])
assert result.exit_code == 0
assert "--binding" in result.output
def test_wf_draft_create_from_capability_help_exists() -> None:
result = runner.invoke(app, ["draft", "create-from-capability", "--help"])
assert result.exit_code == 0
assert "--title" in result.output
+345
View File
@@ -0,0 +1,345 @@
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from typer.testing import CliRunner
from wf_artifacts import FileWorkflowArtifactStore, WorkflowDeployment
from wf_cli.app import app
from wf_cli.context import CliContext, load_cli_context
from wf_cli.formats import ListOutputFormat, render_list_payload
from wf_cli.io import CliInputError, parse_bindings, parse_json_value
from tests.wf_mcp.test_support import echo_tool, local_temp_root
from tests.wf_mcp.workflow_surface.conftest import echo_artifact
def test_render_list_payload_ids_uses_requested_id_field() -> None:
payload = {"capabilities": [{"name": "wf.std.truthy"}, {"name": "wf.std.add"}]}
rendered = render_list_payload(
payload,
collection_key="capabilities",
output_format=ListOutputFormat.IDS,
id_field="name",
)
assert rendered == "wf.std.truthy\nwf.std.add"
def test_render_list_payload_compact_includes_summary_fields() -> None:
payload = {
"nodes": [
{
"name": "workflow.echo.v1",
"kind": "workflow",
"display_name": "Echo",
}
]
}
rendered = render_list_payload(
payload,
collection_key="nodes",
output_format=ListOutputFormat.COMPACT,
id_field="name",
summary_fields=("kind", "display_name"),
)
assert rendered == "workflow.echo.v1\tkind=workflow\tdisplay_name=Echo"
def test_render_list_payload_json_returns_pretty_json() -> None:
payload = {"deployments": [{"id": "echo.personal"}]}
rendered = render_list_payload(
payload,
collection_key="deployments",
output_format=ListOutputFormat.JSON,
id_field="id",
)
parsed = json.loads(rendered)
assert parsed["deployments"][0]["id"] == "echo.personal"
def test_parse_json_value_accepts_arrays_for_json_patch() -> None:
value = parse_json_value(
input_json='[{"op":"replace","path":"/name","value":"x"}]', input_file=None
)
assert isinstance(value, list)
assert value[0]["op"] == "replace"
def test_parse_json_value_rejects_both_input_modes(tmp_path) -> None:
payload = tmp_path / "payload.json"
payload.write_text("{}", encoding="utf-8")
with pytest.raises(CliInputError, match="mutually exclusive"):
parse_json_value(input_json="{}", input_file=payload)
def test_parse_bindings_rejects_invalid_shape() -> None:
with pytest.raises(CliInputError, match="logical=concrete"):
parse_bindings(["demo.personal"])
runner = CliRunner()
def _write_cli_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 _load_cli_context_with_specs(config_path: str | Path) -> CliContext:
"""Seed executable demo specs for CLI tests only.
Config loading registers connections and stores; it does not register
in-memory test NodeSpecs. Production source registration remains outside
this CLI slice.
"""
context = load_cli_context(config_path)
context.service.register_specs("demo.personal", echo_tool)
return context
def test_wf_cap_list_outputs_json() -> None:
root = local_temp_root() / "wf_cli_cap_list"
root.mkdir(parents=True, exist_ok=True)
config_path = _write_cli_config(root)
with patch("wf_cli.commands.caps.load_cli_context", _load_cli_context_with_specs):
result = runner.invoke(
app,
["--config", str(config_path), "cap", "list", "--source", "demo.personal"],
)
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["capabilities"][0]["name"] == "demo.personal.echo_tool"
assert payload["capabilities"][0]["source_id"] == "demo.personal"
def test_wf_cap_list_ids_format() -> None:
root = local_temp_root() / "wf_cli_cap_list_ids"
root.mkdir(parents=True, exist_ok=True)
config_path = _write_cli_config(root)
with patch("wf_cli.commands.caps.load_cli_context", _load_cli_context_with_specs):
result = runner.invoke(
app,
[
"--config",
str(config_path),
"cap",
"list",
"--source",
"demo.personal",
"--format",
"ids",
],
)
assert result.exit_code == 0
assert result.output.strip() == "demo.personal.echo_tool"
def test_wf_cap_inspect_outputs_detail() -> None:
root = local_temp_root() / "wf_cli_cap_inspect"
root.mkdir(parents=True, exist_ok=True)
config_path = _write_cli_config(root)
with patch("wf_cli.commands.caps.load_cli_context", _load_cli_context_with_specs):
result = runner.invoke(
app,
["--config", str(config_path), "cap", "inspect", "demo.personal.echo_tool"],
)
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["name"] == "demo.personal.echo_tool"
assert payload["wrapper_hints"]["input_map"] == {"input.text": "text"}
def _seed_echo_artifact(root: Path) -> Path:
config_path = _write_cli_config(root)
store = FileWorkflowArtifactStore(root / ".wf_mcp_store")
store.save_artifact(echo_artifact())
return config_path
def _seed_echo_deployment(root: Path) -> Path:
config_path = _seed_echo_artifact(root)
store = FileWorkflowArtifactStore(root / ".wf_mcp_store")
store.save_deployment(
WorkflowDeployment(
id="echo.personal",
artifact_id="echo",
artifact_version=1,
bindings=[{"logical_source": "demo", "concrete_source": "demo.personal"}],
)
)
return config_path
def test_wf_artifact_list_and_inspect() -> None:
root = local_temp_root() / "wf_cli_artifacts"
root.mkdir(parents=True, exist_ok=True)
config_path = _seed_echo_artifact(root)
listed = runner.invoke(
app, ["--config", str(config_path), "artifact", "list", "--format", "ids"]
)
inspected = runner.invoke(
app, ["--config", str(config_path), "artifact", "inspect", "echo", "1"]
)
assert listed.exit_code == 0
assert listed.output.strip() == "workflow.echo.v1"
assert inspected.exit_code == 0
payload = json.loads(inspected.output)
assert payload["id"] == "echo"
assert payload["version"] == 1
def test_wf_deploy_list_inspect_save_delete() -> None:
root = local_temp_root() / "wf_cli_deploy_lifecycle"
root.mkdir(parents=True, exist_ok=True)
config_path = _seed_echo_deployment(root)
listed = runner.invoke(
app, ["--config", str(config_path), "deploy", "list", "--format", "ids"]
)
inspected = runner.invoke(
app, ["--config", str(config_path), "deploy", "inspect", "echo.personal"]
)
saved = runner.invoke(
app,
[
"--config",
str(config_path),
"deploy",
"save",
"echo.copy",
"--artifact",
"echo",
"--version",
"1",
"--binding",
"demo=demo.personal",
],
)
deleted = runner.invoke(
app, ["--config", str(config_path), "deploy", "delete", "echo.copy"]
)
assert listed.exit_code == 0
assert listed.output.strip() == "echo.personal"
assert inspected.exit_code == 0
assert json.loads(inspected.output)["id"] == "echo.personal"
assert saved.exit_code == 0
assert json.loads(saved.output)["deployment_id"] == "echo.copy"
assert deleted.exit_code == 0
assert json.loads(deleted.output)["deleted"] is True
def test_wf_draft_create_patch_validate_save() -> None:
root = local_temp_root() / "wf_cli_draft_lifecycle"
root.mkdir(parents=True, exist_ok=True)
config_path = _write_cli_config(root)
with patch("wf_cli.commands.drafts.load_cli_context", _load_cli_context_with_specs):
created = runner.invoke(
app,
[
"--config",
str(config_path),
"draft",
"create-from-capability",
"echo_workspace",
"demo.personal.echo_tool",
"--name",
"echo_workspace",
],
)
listed = runner.invoke(
app, ["--config", str(config_path), "draft", "list", "--format", "ids"]
)
inspected = runner.invoke(
app,
[
"--config",
str(config_path),
"draft",
"inspect",
"echo_workspace",
"--include-draft",
],
)
revision = json.loads(created.output)["revision"]
patched = runner.invoke(
app,
[
"--config",
str(config_path),
"draft",
"patch",
"echo_workspace",
"--revision",
str(revision),
"--input",
'[{"op":"replace","path":"/name","value":"echo_workspace_renamed"}]',
],
)
validated = runner.invoke(
app, ["--config", str(config_path), "draft", "validate", "echo_workspace"]
)
saved = runner.invoke(
app,
[
"--config",
str(config_path),
"draft",
"save",
"echo_workspace",
"--artifact",
"echo_workspace",
"--version",
"1",
"--title",
"Echo Workspace",
"--outcome",
"completed",
"--binding",
"demo=demo.personal",
],
)
assert created.exit_code == 0
assert json.loads(created.output)["workspace_id"] == "echo_workspace"
assert listed.exit_code == 0
assert listed.output.strip() == "echo_workspace"
assert inspected.exit_code == 0
assert "draft" in json.loads(inspected.output)
assert patched.exit_code == 0
assert json.loads(patched.output)["revision"] == revision + 1
assert validated.exit_code == 0
assert json.loads(validated.output)["status"] == "valid"
assert saved.exit_code == 0
assert json.loads(saved.output)["saved"] is True