weftai-cli 0.1.0a1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- weftai_cli-0.1.0a1/.gitignore +30 -0
- weftai_cli-0.1.0a1/PKG-INFO +14 -0
- weftai_cli-0.1.0a1/README.md +3 -0
- weftai_cli-0.1.0a1/pyproject.toml +29 -0
- weftai_cli-0.1.0a1/src/weftai/cli/__init__.py +17 -0
- weftai_cli-0.1.0a1/src/weftai/cli/commands.py +184 -0
- weftai_cli-0.1.0a1/src/weftai/cli/domain.py +25 -0
- weftai_cli-0.1.0a1/src/weftai/cli/parse.py +49 -0
- weftai_cli-0.1.0a1/src/weftai/cli/py.typed +0 -0
- weftai_cli-0.1.0a1/src/weftai/cli/scaffold.py +74 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*$py.class
|
|
4
|
+
*.so
|
|
5
|
+
.Python
|
|
6
|
+
.venv/
|
|
7
|
+
venv/
|
|
8
|
+
dist/
|
|
9
|
+
build/
|
|
10
|
+
*.egg-info/
|
|
11
|
+
.eggs/
|
|
12
|
+
.coverage
|
|
13
|
+
.coverage.*
|
|
14
|
+
htmlcov/
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
.mypy_cache/
|
|
17
|
+
.ruff_cache/
|
|
18
|
+
.pyright/
|
|
19
|
+
.tox/
|
|
20
|
+
.env
|
|
21
|
+
.env.*
|
|
22
|
+
!.env.example
|
|
23
|
+
uv.lock.bak
|
|
24
|
+
.DS_Store
|
|
25
|
+
Thumbs.db
|
|
26
|
+
.idea/
|
|
27
|
+
.vscode/
|
|
28
|
+
*.tsbuildinfo
|
|
29
|
+
.coverage.*
|
|
30
|
+
htmlcov/
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: weftai-cli
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Weftai command-line interface
|
|
5
|
+
Author: Rex Technologies
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Requires-Dist: weftai
|
|
9
|
+
Requires-Dist: weftai-mcp
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# weftai-cli
|
|
13
|
+
|
|
14
|
+
Command-line interface. Install: `pip install weftai-cli`. Console script: `weftai`.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling", "hatch-cada>=1.0.3"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "weftai-cli"
|
|
7
|
+
version = "0.1.0a1"
|
|
8
|
+
description = "Weftai command-line interface"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
authors = [{ name = "Rex Technologies" }]
|
|
13
|
+
dependencies = ["weftai", "weftai-mcp"]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
weftai = "weftai.cli:main"
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build]
|
|
19
|
+
dev-mode-dirs = ["src"]
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["src/weftai"]
|
|
23
|
+
|
|
24
|
+
[tool.hatch.metadata.hooks.cada]
|
|
25
|
+
strategy = "pin"
|
|
26
|
+
|
|
27
|
+
[tool.uv.sources]
|
|
28
|
+
weftai = { workspace = true }
|
|
29
|
+
weftai-mcp = { workspace = true }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from weftai.cli.commands import USAGE, Io, dispatch
|
|
7
|
+
from weftai.cli.parse import parse_argv
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main(argv: list[str] | None = None) -> int:
|
|
11
|
+
args = parse_argv(sys.argv[1:] if argv is None else argv)
|
|
12
|
+
io = Io(sys.stdout, sys.stderr)
|
|
13
|
+
return asyncio.run(dispatch(args, io))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, TextIO
|
|
6
|
+
|
|
7
|
+
from weftai import create_runtime, format_issues, validate_plan
|
|
8
|
+
from weftai.cli.domain import load_domain
|
|
9
|
+
from weftai.cli.parse import CliArgs, bool_flag, flag, parse_argv
|
|
10
|
+
from weftai.cli.scaffold import write_scaffold
|
|
11
|
+
|
|
12
|
+
USAGE = """Usage:
|
|
13
|
+
weftai run <plan.json> --domain <file> [--fixture <file>] [--trace out.json] [--format text|json]
|
|
14
|
+
weftai validate <plan.json> --domain <file>
|
|
15
|
+
weftai describe --domain <file> [--json]
|
|
16
|
+
weftai trace <trace.json>
|
|
17
|
+
weftai mcp --domain <file> [--fixture <file>] [--name <name>]
|
|
18
|
+
weftai init [dir]
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Io:
|
|
23
|
+
def __init__(self, stdout: Any, stderr: Any) -> None:
|
|
24
|
+
self.stdout = stdout
|
|
25
|
+
self.stderr = stderr
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def dispatch(args: CliArgs, io: Io, options: dict[str, Any] | None = None) -> int:
|
|
29
|
+
opts = options or {}
|
|
30
|
+
command = args.command
|
|
31
|
+
if command is None or command == "help":
|
|
32
|
+
io.stdout.write(USAGE)
|
|
33
|
+
return 0
|
|
34
|
+
if command == "run":
|
|
35
|
+
return await _run(args, io)
|
|
36
|
+
if command == "validate":
|
|
37
|
+
return await _validate(args, io)
|
|
38
|
+
if command == "describe":
|
|
39
|
+
return await _describe(args, io)
|
|
40
|
+
if command == "trace":
|
|
41
|
+
return _trace(args, io)
|
|
42
|
+
if command == "mcp":
|
|
43
|
+
return await _mcp(args, io, opts.get("mcpTransport"))
|
|
44
|
+
if command == "init":
|
|
45
|
+
return _init(args, io)
|
|
46
|
+
io.stderr.write(f"Unknown command '{command}'.\n{USAGE}")
|
|
47
|
+
return 2
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def _run(args: CliArgs, io: Io) -> int:
|
|
51
|
+
plan_path = args.positionals[0] if args.positionals else None
|
|
52
|
+
domain_path = flag(args, "domain")
|
|
53
|
+
if plan_path is None or domain_path is None:
|
|
54
|
+
io.stderr.write("run requires <plan.json> and --domain <file>.\n")
|
|
55
|
+
return 2
|
|
56
|
+
domain = load_domain(domain_path)
|
|
57
|
+
runtime = create_runtime({"registry": domain.registry})
|
|
58
|
+
ctx = domain.create_context(flag(args, "fixture"))
|
|
59
|
+
if hasattr(ctx, "__await__"):
|
|
60
|
+
ctx = await ctx
|
|
61
|
+
plan = _read_json(plan_path)
|
|
62
|
+
result = await runtime.execute(plan, {"ctx": ctx})
|
|
63
|
+
fmt = flag(args, "format") or "text"
|
|
64
|
+
if fmt == "json":
|
|
65
|
+
io.stdout.write(json.dumps(_public_result(result), indent=2) + "\n")
|
|
66
|
+
else:
|
|
67
|
+
io.stdout.write(f"{result['text']}\n")
|
|
68
|
+
trace_path = flag(args, "trace")
|
|
69
|
+
if trace_path is not None:
|
|
70
|
+
Path(trace_path).resolve().write_text(json.dumps(result["trace"], indent=2) + "\n", encoding="utf-8")
|
|
71
|
+
return 0 if result["ok"] else 1
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def _validate(args: CliArgs, io: Io) -> int:
|
|
75
|
+
plan_path = args.positionals[0] if args.positionals else None
|
|
76
|
+
domain_path = flag(args, "domain")
|
|
77
|
+
if plan_path is None or domain_path is None:
|
|
78
|
+
io.stderr.write("validate requires <plan.json> and --domain <file>.\n")
|
|
79
|
+
return 2
|
|
80
|
+
domain = load_domain(domain_path)
|
|
81
|
+
plan = _read_json(plan_path)
|
|
82
|
+
validation = validate_plan(plan, domain.registry)
|
|
83
|
+
if validation["ok"]:
|
|
84
|
+
io.stdout.write(f"OK: {len(validation['plan'].steps)} step(s).\n")
|
|
85
|
+
return 0
|
|
86
|
+
io.stderr.write(f"{format_issues(validation['issues'])}\n")
|
|
87
|
+
return 1
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def _describe(args: CliArgs, io: Io) -> int:
|
|
91
|
+
domain_path = flag(args, "domain")
|
|
92
|
+
if domain_path is None:
|
|
93
|
+
io.stderr.write("describe requires --domain <file>.\n")
|
|
94
|
+
return 2
|
|
95
|
+
domain = load_domain(domain_path)
|
|
96
|
+
if bool_flag(args, "json"):
|
|
97
|
+
io.stdout.write(
|
|
98
|
+
json.dumps(domain.registry.plan_schema({"style": "union", "strict": True}), indent=2) + "\n"
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
io.stdout.write(f"{domain.registry.describe()}\n")
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _trace(args: CliArgs, io: Io) -> int:
|
|
106
|
+
path = args.positionals[0] if args.positionals else None
|
|
107
|
+
if path is None:
|
|
108
|
+
io.stderr.write("trace requires <trace.json>.\n")
|
|
109
|
+
return 2
|
|
110
|
+
trace = _read_json(path)
|
|
111
|
+
io.stdout.write(f"{format_trace(trace)}\n")
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def _mcp(args: CliArgs, io: Io, transport: Any) -> int:
|
|
116
|
+
domain_path = flag(args, "domain")
|
|
117
|
+
if domain_path is None:
|
|
118
|
+
io.stderr.write("mcp requires --domain <file>.\n")
|
|
119
|
+
return 2
|
|
120
|
+
from weftai.mcp import create_mcp_server
|
|
121
|
+
|
|
122
|
+
domain = load_domain(domain_path)
|
|
123
|
+
runtime = create_runtime({"registry": domain.registry})
|
|
124
|
+
ctx = domain.create_context(flag(args, "fixture"))
|
|
125
|
+
if hasattr(ctx, "__await__"):
|
|
126
|
+
ctx = await ctx
|
|
127
|
+
mcp = create_mcp_server(runtime, {"name": flag(args, "name") or "weftai", "ctx": ctx})
|
|
128
|
+
io.stderr.write("Serving MCP on stdio. Press Ctrl+C to stop.\n")
|
|
129
|
+
await mcp.connect_stdio(transport)
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _init(args: CliArgs, io: Io) -> int:
|
|
134
|
+
directory = str(Path(args.positionals[0] if args.positionals else ".").resolve())
|
|
135
|
+
written = write_scaffold(directory)
|
|
136
|
+
io.stdout.write(f"Wrote {len(written)} files in {directory}.\n")
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _read_json(path: str) -> Any:
|
|
141
|
+
return json.loads(Path(path).resolve().read_text(encoding="utf-8"))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _public_result(result: dict[str, Any]) -> dict[str, Any]:
|
|
145
|
+
return {
|
|
146
|
+
"ok": result["ok"],
|
|
147
|
+
"text": result["text"],
|
|
148
|
+
"durationMs": result["durationMs"],
|
|
149
|
+
"steps": [
|
|
150
|
+
{
|
|
151
|
+
"id": step["id"],
|
|
152
|
+
"operation": step["operation"],
|
|
153
|
+
"status": step["status"],
|
|
154
|
+
"count": step.get("count"),
|
|
155
|
+
"notices": step.get("notices"),
|
|
156
|
+
"error": step.get("error"),
|
|
157
|
+
}
|
|
158
|
+
for step in result["steps"]
|
|
159
|
+
],
|
|
160
|
+
"trace": result["trace"],
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def format_trace(trace: dict[str, Any]) -> str:
|
|
165
|
+
header = _pad("STEP", 12) + _pad("OP", 24) + _pad("STATUS", 10) + _pad("COUNT", 8) + _pad("MS", 8) + "NOTICES"
|
|
166
|
+
rows = []
|
|
167
|
+
for step in trace.get("steps") or []:
|
|
168
|
+
notices = "; ".join(step.get("notices") or [])
|
|
169
|
+
count = step.get("output", {}) or {}
|
|
170
|
+
count_val = count.get("count") if isinstance(count, dict) else None
|
|
171
|
+
rows.append(
|
|
172
|
+
_pad(step["id"], 12)
|
|
173
|
+
+ _pad(step["operation"], 24)
|
|
174
|
+
+ _pad(step["status"], 10)
|
|
175
|
+
+ _pad("-" if count_val is None else str(count_val), 8)
|
|
176
|
+
+ _pad(str(step.get("durationMs", 0)), 8)
|
|
177
|
+
+ notices
|
|
178
|
+
)
|
|
179
|
+
ok = "ok" if trace.get("ok") else "failed"
|
|
180
|
+
return "\n".join([f"{ok} {trace.get('durationMs', 0)}ms {len(trace.get('steps') or [])} step(s)", header, *rows])
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _pad(value: str, width: int) -> str:
|
|
184
|
+
return f"{value} " if len(value) >= width else value.ljust(width)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load_domain(path: str) -> Any:
|
|
9
|
+
resolved = Path(path).resolve()
|
|
10
|
+
spec = importlib.util.spec_from_file_location("weftai_domain", resolved)
|
|
11
|
+
if spec is None or spec.loader is None:
|
|
12
|
+
raise RuntimeError(f"Could not load domain file '{resolved}'.")
|
|
13
|
+
module = importlib.util.module_from_spec(spec)
|
|
14
|
+
spec.loader.exec_module(module)
|
|
15
|
+
registry = getattr(module, "registry", None)
|
|
16
|
+
if registry is None:
|
|
17
|
+
registry = getattr(module, "default", None)
|
|
18
|
+
create_context = getattr(module, "create_context", None)
|
|
19
|
+
if create_context is None:
|
|
20
|
+
create_context = getattr(module, "createContext", None)
|
|
21
|
+
if registry is None or create_context is None:
|
|
22
|
+
raise RuntimeError(
|
|
23
|
+
f"Domain file '{resolved}' must export registry and create_context(fixture_path=None)."
|
|
24
|
+
)
|
|
25
|
+
return type("Domain", (), {"registry": registry, "create_context": create_context})()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CliArgs:
|
|
7
|
+
def __init__(self, command: str | None, positionals: list[str], flags: dict[str, str | bool]) -> None:
|
|
8
|
+
self.command = command
|
|
9
|
+
self.positionals = positionals
|
|
10
|
+
self.flags = flags
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_argv(argv: list[str]) -> CliArgs:
|
|
14
|
+
positionals: list[str] = []
|
|
15
|
+
flags: dict[str, str | bool] = {}
|
|
16
|
+
i = 0
|
|
17
|
+
while i < len(argv):
|
|
18
|
+
arg = argv[i]
|
|
19
|
+
if arg == "--":
|
|
20
|
+
positionals.extend(argv[i + 1 :])
|
|
21
|
+
break
|
|
22
|
+
if arg.startswith("--"):
|
|
23
|
+
eq = arg.find("=")
|
|
24
|
+
if eq >= 0:
|
|
25
|
+
flags[arg[2:eq]] = arg[eq + 1 :]
|
|
26
|
+
i += 1
|
|
27
|
+
continue
|
|
28
|
+
key = arg[2:]
|
|
29
|
+
nxt = argv[i + 1] if i + 1 < len(argv) else None
|
|
30
|
+
if nxt is not None and not nxt.startswith("-"):
|
|
31
|
+
flags[key] = nxt
|
|
32
|
+
i += 2
|
|
33
|
+
else:
|
|
34
|
+
flags[key] = True
|
|
35
|
+
i += 1
|
|
36
|
+
continue
|
|
37
|
+
positionals.append(arg)
|
|
38
|
+
i += 1
|
|
39
|
+
command = positionals[0] if positionals else None
|
|
40
|
+
return CliArgs(command, positionals[1:], flags)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def flag(args: CliArgs, name: str) -> str | None:
|
|
44
|
+
value = args.flags.get(name)
|
|
45
|
+
return value if isinstance(value, str) else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def bool_flag(args: CliArgs, name: str) -> bool:
|
|
49
|
+
return args.flags.get(name) is True or args.flags.get(name) == "true"
|
|
File without changes
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
DOMAIN = '''from weftai import (
|
|
6
|
+
BaseModel,
|
|
7
|
+
collection,
|
|
8
|
+
create_registry,
|
|
9
|
+
define_operation_for,
|
|
10
|
+
object_schema,
|
|
11
|
+
ref,
|
|
12
|
+
string_schema,
|
|
13
|
+
standard_operations,
|
|
14
|
+
)
|
|
15
|
+
from weftai.schema.spec import object_schema as obj
|
|
16
|
+
from weftai.schema.types import FieldSpec
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Item(BaseModel):
|
|
20
|
+
id: str
|
|
21
|
+
label: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
Items = collection(
|
|
25
|
+
"items",
|
|
26
|
+
Item,
|
|
27
|
+
label=lambda item: item.label,
|
|
28
|
+
key=lambda item: item.id,
|
|
29
|
+
fields=lambda _ctx: [FieldSpec("label", lambda item: item.label)],
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
define = define_operation_for()
|
|
33
|
+
|
|
34
|
+
find = define(
|
|
35
|
+
{
|
|
36
|
+
"name": "items.find",
|
|
37
|
+
"description": "Find items by label.",
|
|
38
|
+
"input": obj({"query": string_schema()}),
|
|
39
|
+
"output": Items,
|
|
40
|
+
"run": lambda ctx: [item for item in ctx.ctx["items"] if ctx.input["query"].lower() in item.label.lower()],
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
registry = create_registry({"operations": [find, *standard_operations(Items)]})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def create_context(fixture_path: str | None = None) -> dict:
|
|
48
|
+
return {"items": [Item(id="1", label="Example")]}
|
|
49
|
+
'''
|
|
50
|
+
|
|
51
|
+
TEST = '''import pytest
|
|
52
|
+
from weftai.testing import create_test_runtime, format_snapshot, to_have_matched
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.mark.asyncio
|
|
56
|
+
async def test_find():
|
|
57
|
+
from domain import registry, create_context
|
|
58
|
+
|
|
59
|
+
harness = create_test_runtime(registry, create_context())
|
|
60
|
+
result = await harness.run_steps([{"id": "hit", "op": "items.find", "input": {"query": "Example"}}])
|
|
61
|
+
to_have_matched(result, "hit", 1)
|
|
62
|
+
assert "Example" in format_snapshot(result)
|
|
63
|
+
'''
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def write_scaffold(directory: str) -> list[str]:
|
|
67
|
+
root = Path(directory)
|
|
68
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
written = []
|
|
70
|
+
for name, contents in (("domain.py", DOMAIN), ("test_domain.py", TEST)):
|
|
71
|
+
path = root / name
|
|
72
|
+
path.write_text(contents, encoding="utf-8", newline="\n")
|
|
73
|
+
written.append(str(path))
|
|
74
|
+
return written
|