wf cli foundation

This commit is contained in:
lda
2026-06-01 02:27:43 +07:00 Verified
parent 6dab695b53
commit 3cd7f529e9
18 changed files with 318 additions and 0 deletions
+1
View File
@@ -17,6 +17,7 @@ dependencies = [
]
[project.scripts]
wf = "wf_cli.app:main"
wf-mcp = "wf_mcp.cli:main"
[dependency-groups]
+1
View File
@@ -0,0 +1 @@
"""Workflow platform command-line interface."""
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from typing import Annotated
import typer
from .commands import artifacts, caps, deployments, docs, drafts, explain, runs, schema
app = typer.Typer(
name="wf",
help="Workflow platform CLI.",
no_args_is_help=True,
)
@app.callback()
def root(
config: Annotated[
str,
typer.Option(
"--config",
help="Path to workflow/MCP config JSON.",
),
] = "wf_mcp.config.json",
) -> None:
"""Run workflow platform commands."""
# The root callback owns global options only. Command modules should load
# context explicitly so tests can call command functions without Typer state.
_ = config
app.add_typer(caps.app, name="cap")
app.add_typer(drafts.app, name="draft")
app.add_typer(artifacts.app, name="artifact")
app.add_typer(deployments.app, name="deploy")
app.add_typer(runs.app, name="run")
app.add_typer(docs.app, name="docs")
app.add_typer(schema.app, name="schema")
app.add_typer(explain.app, name="explain")
def main() -> None:
"""Console script entrypoint for `wf`."""
app()
+14
View File
@@ -0,0 +1,14 @@
"""Typer command groups for the wf CLI."""
from . import artifacts, caps, deployments, docs, drafts, explain, runs, schema
__all__ = [
"artifacts",
"caps",
"deployments",
"docs",
"drafts",
"explain",
"runs",
"schema",
]
+9
View File
@@ -0,0 +1,9 @@
from __future__ import annotations
import typer
app = typer.Typer(
name="artifact",
help="List and inspect saved workflow artifacts.",
no_args_is_help=True,
)
+9
View File
@@ -0,0 +1,9 @@
from __future__ import annotations
import typer
app = typer.Typer(
name="cap",
help="Inspect and call workflow capabilities.",
no_args_is_help=True,
)
+9
View File
@@ -0,0 +1,9 @@
from __future__ import annotations
import typer
app = typer.Typer(
name="deploy",
help="Save, inspect, validate, and delete workflow deployments.",
no_args_is_help=True,
)
+9
View File
@@ -0,0 +1,9 @@
from __future__ import annotations
import typer
app = typer.Typer(
name="docs",
help="List and read workflow documentation resources.",
no_args_is_help=True,
)
+9
View File
@@ -0,0 +1,9 @@
from __future__ import annotations
import typer
app = typer.Typer(
name="draft",
help="Create, inspect, patch, validate, and save draft workflows.",
no_args_is_help=True,
)
+9
View File
@@ -0,0 +1,9 @@
from __future__ import annotations
import typer
app = typer.Typer(
name="explain",
help="Explain workflow diagnostic and CLI error codes.",
no_args_is_help=True,
)
+9
View File
@@ -0,0 +1,9 @@
from __future__ import annotations
import typer
app = typer.Typer(
name="run",
help="Run workflow deployments and inspect durable runs.",
no_args_is_help=True,
)
+9
View File
@@ -0,0 +1,9 @@
from __future__ import annotations
import typer
app = typer.Typer(
name="schema",
help="Print expected input shapes for wf commands.",
no_args_is_help=True,
)
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from wf_mcp.broker import build_service_from_config, load_broker_config
from wf_mcp.broker.service import WfMcpService
from wf_mcp.workflow_surface import WorkflowSurfaceHandlers
@dataclass(frozen=True)
class CliContext:
"""Protocol-neutral CLI handle over the current workflow service stack.
V1 intentionally reuses wf_mcp service construction because that is where
config, store, source, artifact, draft, and run wiring currently lives. Keep
this dependency behind context.py so later extraction does not affect every
command module.
"""
config_path: Path
service: WfMcpService
handlers: WorkflowSurfaceHandlers
def load_cli_context(config_path: str | Path) -> CliContext:
"""Load config and build workflow-surface handlers for CLI commands."""
resolved_config_path = Path(config_path)
config = load_broker_config(resolved_config_path)
service = build_service_from_config(config)
return CliContext(
config_path=resolved_config_path,
service=service,
handlers=WorkflowSurfaceHandlers(service),
)
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
class CliInputError(ValueError):
"""Raised when CLI JSON/file input cannot be parsed safely."""
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 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:
return {}
raw = input_json if input_json is not None else _read_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):
raise CliInputError("JSON input must be an object")
return payload
def emit_json(payload: Any) -> None:
"""Write JSON output in the CLI default machine-readable format."""
print(json.dumps(payload, indent=2, sort_keys=True))
def _read_input_file(path: Path | None) -> str:
"""Read a required JSON input file."""
if path is None:
raise CliInputError("input file path is required")
try:
return path.read_text(encoding="utf-8")
except OSError as exc:
raise CliInputError(f"could not read input file {path!s}: {exc}") from exc
View File
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
from typer.testing import CliRunner
from wf_cli.app import app
runner = CliRunner()
def test_wf_help_lists_lifecycle_groups() -> None:
result = runner.invoke(app, ["--help"])
assert result.exit_code == 0
assert "cap" in result.output
assert "draft" in result.output
assert "artifact" in result.output
assert "deploy" in result.output
assert "run" in result.output
assert "docs" in result.output
assert "schema" in result.output
assert "explain" in result.output
def test_wf_run_group_help_exists() -> None:
result = runner.invoke(app, ["run", "--help"])
assert result.exit_code == 0
assert "Run workflow deployments" in result.output
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
import json
from wf_cli.context import load_cli_context
from ..wf_mcp.test_support import local_temp_root
def test_load_cli_context_builds_service_and_handlers() -> None:
tmp_path = local_temp_root() / "wf_cli_context"
tmp_path.mkdir(parents=True, exist_ok=True)
config_path = tmp_path / "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",
)
context = load_cli_context(config_path)
assert context.config_path == config_path
assert context.service.connections.list_all()[0].id == "demo.personal"
assert context.handlers.service is context.service
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
import json
import pytest
from wf_cli.io import CliInputError, emit_json, parse_json_input
def test_parse_json_input_reads_inline_json() -> None:
payload = parse_json_input(input_json='{"text": "hello"}', input_file=None)
assert payload["text"] == "hello"
def test_parse_json_input_reads_file(tmp_path) -> None:
path = tmp_path / "payload.json"
path.write_text('{"text": "from file"}', encoding="utf-8")
payload = parse_json_input(input_json=None, input_file=path)
assert payload["text"] == "from file"
def test_parse_json_input_rejects_both_inline_and_file(tmp_path) -> None:
path = tmp_path / "payload.json"
path.write_text("{}", encoding="utf-8")
with pytest.raises(CliInputError, match="mutually exclusive"):
parse_json_input(input_json="{}", input_file=path)
def test_parse_json_input_rejects_invalid_json() -> None:
with pytest.raises(CliInputError, match="invalid JSON"):
parse_json_input(input_json="{", input_file=None)
def test_emit_json_writes_pretty_json(capsys) -> None:
emit_json({"ok": True, "items": [1]})
captured = capsys.readouterr()
payload = json.loads(captured.out)
assert payload["ok"] is True
assert payload["items"][0] == 1