wf cli foundation
This commit is contained in:
@@ -17,6 +17,7 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
wf = "wf_cli.app:main"
|
||||||
wf-mcp = "wf_mcp.cli:main"
|
wf-mcp = "wf_mcp.cli:main"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Workflow platform command-line interface."""
|
||||||
@@ -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()
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user