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.
@@ -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,3 @@
1
+ # weftai-cli
2
+
3
+ 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