1192 lines
35 KiB
Markdown
1192 lines
35 KiB
Markdown
# wf CLI Discovery And Draft Lifecycle 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:** Complete the minimum CLI authoring loop: discover capabilities, create/patch/validate/save draft workspaces, inspect artifacts, and save/delete deployments.
|
|
|
|
**Architecture:** Keep CLI commands thin over `WorkflowSurfaceHandlers`; do not duplicate workflow validation or artifact logic. Add one small shared output-format helper for list/discovery commands so `ids` and `compact` output do not get copy-pasted command by command.
|
|
|
|
**Tech Stack:** Python 3.14, Typer, Pydantic v2 models already used by handlers, existing `wf_cli.context`, existing `wf_cli.io`, pytest `CliRunner`.
|
|
|
|
---
|
|
|
|
## Scope
|
|
|
|
Implement these CLI commands:
|
|
|
|
```text
|
|
wf cap list
|
|
wf cap inspect
|
|
wf artifact list
|
|
wf artifact inspect
|
|
wf draft list
|
|
wf draft inspect
|
|
wf draft create-from-capability
|
|
wf draft patch
|
|
wf draft validate
|
|
wf draft save
|
|
wf deploy list
|
|
wf deploy inspect
|
|
wf deploy save
|
|
wf deploy delete
|
|
```
|
|
|
|
Already implemented and not part of this slice:
|
|
|
|
```text
|
|
wf deploy validate
|
|
wf run start
|
|
wf run inspect
|
|
wf run trace
|
|
wf explain
|
|
```
|
|
|
|
Do not add targeted draft editing helpers in this slice:
|
|
|
|
```text
|
|
wf draft step add
|
|
wf draft route set
|
|
wf draft output set
|
|
wf draft field add
|
|
```
|
|
|
|
Those are the next ergonomic layer after raw lifecycle coverage is proven.
|
|
|
|
## Output Contracts
|
|
|
|
`json` is default everywhere.
|
|
|
|
List/discovery commands:
|
|
|
|
```text
|
|
wf cap list --format json|ids|compact
|
|
wf artifact list --format json|ids|compact
|
|
wf draft list --format json|ids|compact
|
|
wf deploy list --format json|ids|compact
|
|
```
|
|
|
|
Details/mutations remain JSON-only:
|
|
|
|
```text
|
|
wf cap inspect
|
|
wf artifact inspect
|
|
wf draft inspect
|
|
wf draft create-from-capability
|
|
wf draft patch
|
|
wf draft validate
|
|
wf draft save
|
|
wf deploy inspect
|
|
wf deploy save
|
|
wf deploy delete
|
|
```
|
|
|
|
Do not implement `table`.
|
|
|
|
## File Structure
|
|
|
|
Create:
|
|
|
|
```text
|
|
src/wf_cli/formats.py
|
|
tests/wf_cli/test_discovery_lifecycle.py
|
|
```
|
|
|
|
Modify:
|
|
|
|
```text
|
|
src/wf_cli/io.py
|
|
src/wf_cli/commands/caps.py
|
|
src/wf_cli/commands/artifacts.py
|
|
src/wf_cli/commands/drafts.py
|
|
src/wf_cli/commands/deployments.py
|
|
tests/wf_cli/test_app.py
|
|
```
|
|
|
|
Do not modify:
|
|
|
|
```text
|
|
src/wf_mcp/workflow_surface/handlers.py
|
|
src/wf_core/
|
|
src/wf_artifacts/
|
|
```
|
|
|
|
If a handler shape is inconvenient, call it as-is and document the CLI limitation in a test or command docstring. Do not add new workflow-surface behavior in this slice.
|
|
|
|
## Task 1: Shared CLI Format And JSON Value Helpers
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/wf_cli/formats.py`
|
|
- Modify: `src/wf_cli/io.py`
|
|
- Test: `tests/wf_cli/test_discovery_lifecycle.py`
|
|
|
|
- [ ] **Step 1: Add focused helper tests**
|
|
|
|
Create `tests/wf_cli/test_discovery_lifecycle.py` with:
|
|
|
|
```python
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from wf_cli.formats import ListOutputFormat, render_list_payload
|
|
from wf_cli.io import CliInputError, parse_json_value
|
|
|
|
|
|
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)
|
|
```
|
|
|
|
- [ ] **Step 2: Run failing helper tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run pytest tests/wf_cli/test_discovery_lifecycle.py -q
|
|
```
|
|
|
|
Expected: fail because `wf_cli.formats` and `parse_json_value` do not exist.
|
|
|
|
- [ ] **Step 3: Implement list formatting helper**
|
|
|
|
Create `src/wf_cli/formats.py`:
|
|
|
|
```python
|
|
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)
|
|
```
|
|
|
|
- [ ] **Step 4: Add JSON value parser**
|
|
|
|
Modify `src/wf_cli/io.py`:
|
|
|
|
```python
|
|
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
|
|
```
|
|
|
|
Then simplify `parse_json_input` to call this helper:
|
|
|
|
```python
|
|
def parse_json_input(
|
|
*,
|
|
input_json: str | None,
|
|
input_file: Path | None,
|
|
) -> dict[str, Any]:
|
|
"""Parse exactly one JSON object from inline JSON or a file path."""
|
|
if input_json is None and input_file is None:
|
|
return {}
|
|
payload = parse_json_value(input_json=input_json, input_file=input_file)
|
|
if not isinstance(payload, dict):
|
|
raise CliInputError("JSON input must be an object")
|
|
return payload
|
|
```
|
|
|
|
- [ ] **Step 5: Run helper tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run pytest tests/wf_cli/test_discovery_lifecycle.py -q
|
|
```
|
|
|
|
Expected: pass.
|
|
|
|
## Task 2: Capability Discovery Commands
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/wf_cli/commands/caps.py`
|
|
- Test: `tests/wf_cli/test_discovery_lifecycle.py`
|
|
- Test: `tests/wf_cli/test_app.py`
|
|
|
|
- [ ] **Step 1: Add capability CLI tests**
|
|
|
|
Replace the import block at the top of `tests/wf_cli/test_discovery_lifecycle.py`
|
|
with the complete import set used by this slice:
|
|
|
|
```python
|
|
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_json_value
|
|
|
|
from tests.wf_mcp.test_support import echo_tool, local_temp_root
|
|
from tests.wf_mcp.workflow_surface.conftest import echo_artifact
|
|
```
|
|
|
|
Then append to `tests/wf_cli/test_discovery_lifecycle.py`:
|
|
|
|
```python
|
|
|
|
|
|
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"}
|
|
```
|
|
|
|
Append to `tests/wf_cli/test_app.py`:
|
|
|
|
```python
|
|
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
|
|
```
|
|
|
|
- [ ] **Step 2: Run capability tests to verify failure**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run pytest tests/wf_cli/test_discovery_lifecycle.py tests/wf_cli/test_app.py -q
|
|
```
|
|
|
|
Expected: fail because `cap list` and `cap inspect` do not exist.
|
|
|
|
- [ ] **Step 3: Implement capability commands**
|
|
|
|
Replace `src/wf_cli/commands/caps.py` with:
|
|
|
|
```python
|
|
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.formats import ListOutputFormat, emit_list_payload
|
|
from wf_cli.io import emit_json
|
|
|
|
app = typer.Typer(
|
|
name="cap",
|
|
help="Inspect and call workflow capabilities.",
|
|
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)
|
|
```
|
|
|
|
- [ ] **Step 4: Run capability tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run pytest tests/wf_cli/test_discovery_lifecycle.py tests/wf_cli/test_app.py -q
|
|
```
|
|
|
|
Expected: pass.
|
|
|
|
## Task 3: Artifact And Deployment Store Commands
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/wf_cli/commands/artifacts.py`
|
|
- Modify: `src/wf_cli/commands/deployments.py`
|
|
- Test: `tests/wf_cli/test_discovery_lifecycle.py`
|
|
- Test: `tests/wf_cli/test_app.py`
|
|
|
|
- [ ] **Step 1: Add artifact/deployment CLI tests**
|
|
|
|
Append to `tests/wf_cli/test_discovery_lifecycle.py`:
|
|
|
|
```python
|
|
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
|
|
```
|
|
|
|
Append to `tests/wf_cli/test_app.py`:
|
|
|
|
```python
|
|
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
|
|
```
|
|
|
|
- [ ] **Step 2: Run artifact/deployment tests to verify failure**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run pytest tests/wf_cli/test_discovery_lifecycle.py tests/wf_cli/test_app.py -q
|
|
```
|
|
|
|
Expected: fail because artifact and deployment lifecycle commands do not exist.
|
|
|
|
- [ ] **Step 3: Implement artifact commands**
|
|
|
|
Replace `src/wf_cli/commands/artifacts.py` with:
|
|
|
|
```python
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from typing import Annotated, Literal
|
|
|
|
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(
|
|
name="artifact",
|
|
help="List and inspect saved workflow artifacts.",
|
|
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)
|
|
)
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 4: Implement deployment commands**
|
|
|
|
Extend `src/wf_cli/commands/deployments.py` without removing the existing `validate` command:
|
|
|
|
```python
|
|
from pathlib import Path
|
|
|
|
from wf_cli.formats import ListOutputFormat, emit_list_payload
|
|
from wf_cli.io import CliInputError, emit_json, parse_json_input
|
|
```
|
|
|
|
Add these commands above or below `validate_deployment`:
|
|
|
|
```python
|
|
@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),
|
|
}
|
|
|
|
|
|
def _parse_bindings(bindings: list[str]) -> dict[str, str]:
|
|
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
|
|
```
|
|
|
|
- [ ] **Step 5: Run artifact/deployment tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run pytest tests/wf_cli/test_discovery_lifecycle.py tests/wf_cli/test_app.py -q
|
|
```
|
|
|
|
Expected: pass.
|
|
|
|
## Task 4: Draft Workspace Commands
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/wf_cli/commands/drafts.py`
|
|
- Test: `tests/wf_cli/test_discovery_lifecycle.py`
|
|
- Test: `tests/wf_cli/test_app.py`
|
|
|
|
- [ ] **Step 1: Add draft lifecycle CLI tests**
|
|
|
|
Append to `tests/wf_cli/test_discovery_lifecycle.py`:
|
|
|
|
```python
|
|
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
|
|
```
|
|
|
|
Append to `tests/wf_cli/test_app.py`:
|
|
|
|
```python
|
|
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
|
|
```
|
|
|
|
- [ ] **Step 2: Run draft tests to verify failure**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run pytest tests/wf_cli/test_discovery_lifecycle.py tests/wf_cli/test_app.py -q
|
|
```
|
|
|
|
Expected: fail because draft lifecycle commands do not exist.
|
|
|
|
- [ ] **Step 3: Implement draft commands**
|
|
|
|
Replace `src/wf_cli/commands/drafts.py` with:
|
|
|
|
```python
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
from typing import Annotated, Literal
|
|
|
|
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_json_value
|
|
|
|
app = typer.Typer(
|
|
name="draft",
|
|
help="Create, inspect, patch, validate, and save draft workflows.",
|
|
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."""
|
|
source_bindings = _parse_bindings(binding or [])
|
|
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)
|
|
|
|
|
|
def _parse_bindings(bindings: list[str]) -> dict[str, str]:
|
|
parsed: dict[str, str] = {}
|
|
for item in bindings:
|
|
logical, separator, concrete = item.partition("=")
|
|
if separator != "=" or not logical or not concrete:
|
|
raise typer.BadParameter("--binding must use logical=concrete")
|
|
parsed[logical] = concrete
|
|
return parsed
|
|
```
|
|
|
|
- [ ] **Step 4: Run draft tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run pytest tests/wf_cli/test_discovery_lifecycle.py tests/wf_cli/test_app.py -q
|
|
```
|
|
|
|
Expected: pass.
|
|
|
|
## Task 5: Focused Verification
|
|
|
|
**Files:**
|
|
|
|
- No new files unless lint/format requires cleanup.
|
|
|
|
- [ ] **Step 1: Run focused CLI tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run pytest tests/wf_cli -q
|
|
```
|
|
|
|
Expected: all CLI tests pass.
|
|
|
|
- [ ] **Step 2: Run focused lint**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run ruff check src/wf_cli tests/wf_cli
|
|
```
|
|
|
|
Expected: no lint errors.
|
|
|
|
- [ ] **Step 3: Run focused format check**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run ruff format --check src/wf_cli tests/wf_cli
|
|
```
|
|
|
|
Expected: no formatting changes required. If this fails, run:
|
|
|
|
```bash
|
|
uv run ruff format src/wf_cli tests/wf_cli
|
|
```
|
|
|
|
Then rerun the format check.
|
|
|
|
- [ ] **Step 4: Run type check**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run basedpyright --level error
|
|
```
|
|
|
|
Expected: `0 errors`. If basedpyright exits nonzero only because workspace file enumeration exceeds 10 seconds while still reporting `0 errors`, report that exact condition.
|
|
|
|
## Self-Review Checklist
|
|
|
|
- [ ] List commands support only `json`, `ids`, and `compact`.
|
|
- [ ] Detail and mutation commands stay JSON-only.
|
|
- [ ] No `table` format was added.
|
|
- [ ] Draft patch accepts a JSON Patch array, not only a JSON object.
|
|
- [ ] `deploy save` accepts ergonomic flags and full JSON input.
|
|
- [ ] `draft save --kind wrapper` calls `create_wrapper_from_workspace`.
|
|
- [ ] No workflow-surface handler behavior was reimplemented in CLI code.
|
|
- [ ] Tests assert individual fields, not whole dict equality.
|