pytyr-mcp 0.0.1__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.
Files changed (32) hide show
  1. pytyr_mcp-0.0.1/.github/workflows/ci_pytest.yml +43 -0
  2. pytyr_mcp-0.0.1/.github/workflows/ci_release.yml +33 -0
  3. pytyr_mcp-0.0.1/.github/workflows/release.yml +80 -0
  4. pytyr_mcp-0.0.1/.gitignore +27 -0
  5. pytyr_mcp-0.0.1/PKG-INFO +31 -0
  6. pytyr_mcp-0.0.1/README.md +18 -0
  7. pytyr_mcp-0.0.1/pyproject.toml +38 -0
  8. pytyr_mcp-0.0.1/src/pytyr_mcp/__init__.py +5 -0
  9. pytyr_mcp-0.0.1/src/pytyr_mcp/artifacts.py +184 -0
  10. pytyr_mcp-0.0.1/src/pytyr_mcp/config.py +26 -0
  11. pytyr_mcp-0.0.1/src/pytyr_mcp/invoke.py +110 -0
  12. pytyr_mcp-0.0.1/src/pytyr_mcp/paths.py +21 -0
  13. pytyr_mcp-0.0.1/src/pytyr_mcp/planning/__init__.py +1 -0
  14. pytyr_mcp-0.0.1/src/pytyr_mcp/planning/sample_generator/__init__.py +0 -0
  15. pytyr_mcp-0.0.1/src/pytyr_mcp/planning/sample_generator/results.py +146 -0
  16. pytyr_mcp-0.0.1/src/pytyr_mcp/planning/sample_generator/service.py +187 -0
  17. pytyr_mcp-0.0.1/src/pytyr_mcp/planning/sample_generator/tools.py +45 -0
  18. pytyr_mcp-0.0.1/src/pytyr_mcp/planning/solvability/__init__.py +1 -0
  19. pytyr_mcp-0.0.1/src/pytyr_mcp/planning/solvability/schemas.py +14 -0
  20. pytyr_mcp-0.0.1/src/pytyr_mcp/planning/solvability/service.py +117 -0
  21. pytyr_mcp-0.0.1/src/pytyr_mcp/planning/solvability/tools.py +37 -0
  22. pytyr_mcp-0.0.1/src/pytyr_mcp/roles.py +41 -0
  23. pytyr_mcp-0.0.1/src/pytyr_mcp/server.py +52 -0
  24. pytyr_mcp-0.0.1/tests/__init__.py +1 -0
  25. pytyr_mcp-0.0.1/tests/planning/__init__.py +1 -0
  26. pytyr_mcp-0.0.1/tests/planning/solvability/__init__.py +1 -0
  27. pytyr_mcp-0.0.1/tests/planning/solvability/test_prove_solvability_output.py +124 -0
  28. pytyr_mcp-0.0.1/tests/planning/test_sample_generator_output.py +214 -0
  29. pytyr_mcp-0.0.1/tests/test_packaging.py +56 -0
  30. pytyr_mcp-0.0.1/tests/test_roles.py +91 -0
  31. pytyr_mcp-0.0.1/tests/test_server_output_paths.py +82 -0
  32. pytyr_mcp-0.0.1/uv.lock +1312 -0
@@ -0,0 +1,43 @@
1
+ name: CI Pytest
2
+
3
+ on:
4
+ push:
5
+ branches-ignore:
6
+ - test-pypi
7
+ pull_request:
8
+
9
+ jobs:
10
+ build-and-test:
11
+ runs-on: ${{ matrix.os }}
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ os: [ubuntu-latest, macos-latest]
16
+ python-version: ["3.13"]
17
+
18
+ steps:
19
+ - name: Checkout repository
20
+ uses: actions/checkout@v4
21
+
22
+ - name: Setup Python
23
+ uses: actions/setup-python@v5
24
+ with:
25
+ python-version: ${{ matrix.python-version }}
26
+
27
+ - name: Install package
28
+ run: |
29
+ python -m pip install --no-cache-dir --upgrade pip
30
+ python -m pip install --no-cache-dir --verbose .[dev]
31
+ env:
32
+ PIP_NO_CACHE_DIR: 1
33
+
34
+ - name: Import package
35
+ run: python -c "import pytyr_mcp; print(pytyr_mcp.__version__)"
36
+
37
+ - name: Test
38
+ run: |
39
+ if [ -d tests ]; then
40
+ pytest tests
41
+ else
42
+ echo "No tests directory yet; import check completed."
43
+ fi
@@ -0,0 +1,33 @@
1
+ name: CI Release
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ - dev
8
+ pull_request:
9
+ workflow_dispatch:
10
+
11
+ jobs:
12
+ build_distributions:
13
+ runs-on: ubuntu-latest
14
+
15
+ steps:
16
+ - name: Checkout repository
17
+ uses: actions/checkout@v4
18
+
19
+ - name: Set up Python
20
+ uses: actions/setup-python@v5
21
+ with:
22
+ python-version: "3.13"
23
+
24
+ - name: Install build frontend
25
+ run: |
26
+ python -m pip install --no-cache-dir --upgrade pip
27
+ python -m pip install --no-cache-dir build twine
28
+
29
+ - name: Build distributions
30
+ run: python -m build
31
+
32
+ - name: Check distributions
33
+ run: python -m twine check dist/*
@@ -0,0 +1,80 @@
1
+ name: Release
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ push:
6
+ tags:
7
+ - "v*"
8
+
9
+ jobs:
10
+ build_distributions:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - name: Checkout repository
15
+ uses: actions/checkout@v4
16
+
17
+ - name: Set up Python
18
+ uses: actions/setup-python@v5
19
+ with:
20
+ python-version: "3.13"
21
+
22
+ - name: Install build frontend
23
+ run: |
24
+ python -m pip install --no-cache-dir --upgrade pip
25
+ python -m pip install --no-cache-dir build twine
26
+
27
+ - name: Build distributions
28
+ run: python -m build
29
+
30
+ - name: Check distributions
31
+ run: python -m twine check dist/*
32
+
33
+ - name: Upload distributions artifact
34
+ uses: actions/upload-artifact@v4
35
+ with:
36
+ name: distributions
37
+ path: dist/*
38
+
39
+ publish:
40
+ needs:
41
+ - build_distributions
42
+ runs-on: ubuntu-latest
43
+
44
+ if: github.repository_owner == 'planning-and-learning'
45
+
46
+ permissions:
47
+ id-token: write
48
+
49
+ steps:
50
+ - name: Checkout repository
51
+ uses: actions/checkout@v4
52
+
53
+ - name: Check version matches tag
54
+ if: startsWith(github.ref, 'refs/tags/v')
55
+ run: |
56
+ VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
57
+ TAG=${GITHUB_REF#refs/tags/v}
58
+
59
+ echo "Package version: $VERSION"
60
+ echo "Git tag: $TAG"
61
+
62
+ if [ "$VERSION" != "$TAG" ]; then
63
+ echo "Version mismatch: pyproject.toml=$VERSION tag=$TAG"
64
+ exit 1
65
+ fi
66
+
67
+ - name: Download distributions
68
+ uses: actions/download-artifact@v4
69
+ with:
70
+ name: distributions
71
+ path: dist
72
+
73
+ - name: Inspect distributions
74
+ run: find dist -maxdepth 1 -type f
75
+
76
+ - name: Publish to PyPI
77
+ uses: pypa/gh-action-pypi-publish@release/v1
78
+ with:
79
+ packages-dir: dist
80
+ skip-existing: true
@@ -0,0 +1,27 @@
1
+ .venv/
2
+ .pytest_cache/
3
+ .ruff_cache/
4
+ __pycache__/
5
+ *.py[cod]
6
+ *.egg-info/
7
+ build/
8
+ dist/
9
+ pytyr-mcp-output/
10
+
11
+ venv/
12
+ env/
13
+ ENV/
14
+ .env
15
+ .env.*
16
+ .mypy_cache/
17
+ .pyre/
18
+ .coverage
19
+ coverage.xml
20
+ htmlcov/
21
+ .eggs/
22
+ .wheelhouse/
23
+ .DS_Store
24
+ .idea/
25
+ .vscode/
26
+ *.swp
27
+ *.swo
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytyr-mcp
3
+ Version: 0.0.1
4
+ Summary: MCP server exposing pytyr planning tools for planning-and-learning agents
5
+ Author: Dominik Drexler
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: fastmcp>=2
8
+ Requires-Dist: pytyr<0.1,>=0.0.24
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=8; extra == 'dev'
11
+ Requires-Dist: ruff>=0.8; extra == 'dev'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # pytyr-mcp
15
+
16
+ MCP server exposing pytyr planning tools for planning-and-learning agents.
17
+
18
+ ## Roles
19
+
20
+ Set `PYTYR_MCP_ROLE` before launching the server. The server and invoke CLI fail closed when the role is missing, so restricted agents must be launched with an explicit role:
21
+
22
+ - `planning/sample`: generator inspection and sampling tools.
23
+ - `planning/solvability`: solvability proof tool.
24
+ - `planning`: both sampling and solvability planning tools.
25
+ - `all`: every pytyr MCP tool; use only for trusted, unrestricted local maintenance.
26
+
27
+ Slash roles also accept dotted aliases such as `planning.solvability`. The server rejects missing or unknown roles at startup.
28
+
29
+ ## Output Contract
30
+
31
+ Sampling and solvability tools write layered artifacts under the requested `output_dir`. If that directory already contains output, the tool allocates a numbered child directory such as `run-002` instead of overwriting. Results include `primary` orchestration fields, a structured `summary`, and `items` with relative paths to generated tasks, invalid configs, or proof artifacts.
@@ -0,0 +1,18 @@
1
+ # pytyr-mcp
2
+
3
+ MCP server exposing pytyr planning tools for planning-and-learning agents.
4
+
5
+ ## Roles
6
+
7
+ Set `PYTYR_MCP_ROLE` before launching the server. The server and invoke CLI fail closed when the role is missing, so restricted agents must be launched with an explicit role:
8
+
9
+ - `planning/sample`: generator inspection and sampling tools.
10
+ - `planning/solvability`: solvability proof tool.
11
+ - `planning`: both sampling and solvability planning tools.
12
+ - `all`: every pytyr MCP tool; use only for trusted, unrestricted local maintenance.
13
+
14
+ Slash roles also accept dotted aliases such as `planning.solvability`. The server rejects missing or unknown roles at startup.
15
+
16
+ ## Output Contract
17
+
18
+ Sampling and solvability tools write layered artifacts under the requested `output_dir`. If that directory already contains output, the tool allocates a numbered child directory such as `run-002` instead of overwriting. Results include `primary` orchestration fields, a structured `summary`, and `items` with relative paths to generated tasks, invalid configs, or proof artifacts.
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pytyr-mcp"
7
+ version = "0.0.1"
8
+ description = "MCP server exposing pytyr planning tools for planning-and-learning agents"
9
+ readme = "README.md"
10
+ requires-python = ">=3.13"
11
+ authors = [
12
+ {name = "Dominik Drexler"},
13
+ ]
14
+ dependencies = [
15
+ "fastmcp>=2",
16
+ "pytyr>=0.0.24,<0.1",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = [
21
+ "pytest>=8",
22
+ "ruff>=0.8",
23
+ ]
24
+
25
+ [project.scripts]
26
+ pytyr-mcp-invoke = "pytyr_mcp.invoke:main"
27
+ pytyr-mcp = "pytyr_mcp.server:main"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/pytyr_mcp"]
31
+
32
+ [tool.pytest.ini_options]
33
+ pythonpath = ["src"]
34
+ testpaths = ["tests"]
35
+
36
+ [tool.ruff]
37
+ line-length = 100
38
+ target-version = "py313"
@@ -0,0 +1,5 @@
1
+ """MCP tools for pytyr."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.0.1"
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from pytyr_mcp.paths import relative_to
8
+
9
+
10
+ def write_json(path: Path, data: Any) -> None:
11
+ path.parent.mkdir(parents=True, exist_ok=True)
12
+ with path.open("x", encoding="utf-8") as fh:
13
+ fh.write(json.dumps(data, indent=2, sort_keys=True) + "\n")
14
+
15
+
16
+ RESERVATION_MARKER = ".pytyr-mcp-output"
17
+
18
+
19
+ def has_existing_output(output_dir: Path) -> bool:
20
+ return any(
21
+ (output_dir / name).exists()
22
+ for name in (RESERVATION_MARKER, "summary.json", "summary.md", "raw", "tasks", "domain.pddl")
23
+ )
24
+
25
+
26
+ def _reserve_output_dir(output_dir: Path) -> bool:
27
+ output_dir.mkdir(parents=True, exist_ok=True)
28
+ if has_existing_output(output_dir):
29
+ return False
30
+ try:
31
+ with (output_dir / RESERVATION_MARKER).open("x", encoding="utf-8") as fh:
32
+ fh.write("reserved\n")
33
+ except FileExistsError:
34
+ return False
35
+ return True
36
+
37
+
38
+ def fresh_output_dir(output_dir: Path) -> Path:
39
+ if _reserve_output_dir(output_dir):
40
+ return output_dir
41
+ for index in range(2, 10000):
42
+ candidate = output_dir / f"run-{index:03d}"
43
+ if _reserve_output_dir(candidate):
44
+ return candidate
45
+ raise RuntimeError(f"could not allocate fresh Tyr MCP output directory under {output_dir}")
46
+
47
+
48
+ def _summary_task_path(task: dict[str, Any], output_dir: Path) -> str:
49
+ raw_path = task.get("path")
50
+ if raw_path is None:
51
+ return ""
52
+ path = Path(str(raw_path))
53
+ if not path.is_absolute():
54
+ return path.as_posix()
55
+ try:
56
+ return path.relative_to(output_dir).as_posix()
57
+ except ValueError:
58
+ return "<omitted: outside output_dir>"
59
+
60
+
61
+ def write_solvability_summary(
62
+ *,
63
+ tool: str,
64
+ status: str,
65
+ output_dir: Path,
66
+ metadata: dict[str, Any],
67
+ tasks: list[dict[str, Any]],
68
+ stdout: str = "",
69
+ stderr: str = "",
70
+ ) -> dict[str, Any]:
71
+ output_dir = fresh_output_dir(output_dir)
72
+ raw_dir = output_dir / "raw"
73
+ raw_dir.mkdir(parents=True, exist_ok=True)
74
+ with (raw_dir / "stdout.txt").open("x", encoding="utf-8") as fh:
75
+ fh.write(stdout)
76
+ with (raw_dir / "stderr.txt").open("x", encoding="utf-8") as fh:
77
+ fh.write(stderr)
78
+
79
+ by_status: dict[str, list[dict[str, Any]]] = {}
80
+ for task in tasks:
81
+ by_status.setdefault(str(task["status"]), []).append(task)
82
+
83
+ task_dir = output_dir / "tasks"
84
+ task_items = []
85
+ for index, task in enumerate(tasks, start=1):
86
+ task_id = f"task-{index:03d}"
87
+ task_path = task_dir / f"{task_id}.json"
88
+ task_data = dict(task)
89
+ task_data["path"] = _summary_task_path(task, output_dir)
90
+ task_data.setdefault("schema_version", 1)
91
+ task_data.setdefault("id", task_id)
92
+ write_json(task_path, task_data)
93
+ task_items.append(
94
+ {
95
+ "id": task_id,
96
+ "name": task["name"],
97
+ "status": task["status"],
98
+ "path": relative_to(task_path, output_dir),
99
+ }
100
+ )
101
+
102
+ summary = {
103
+ "schema_version": 1,
104
+ "tool": tool,
105
+ "status": status,
106
+ "metadata": metadata,
107
+ "counts": {
108
+ "tasks": len(tasks),
109
+ "solved": sum(1 for task in tasks if task.get("solved")),
110
+ "unsolved": sum(1 for task in tasks if not task.get("solved")),
111
+ "statuses": len(by_status),
112
+ },
113
+ "by_status": {
114
+ status_name: {
115
+ "count": len(values),
116
+ "tasks": [
117
+ {
118
+ "name": task["name"],
119
+ "path": _summary_task_path(task, output_dir),
120
+ "plan_length": task.get("plan_length"),
121
+ }
122
+ for task in values
123
+ ],
124
+ }
125
+ for status_name, values in sorted(by_status.items())
126
+ },
127
+ "tasks": task_items,
128
+ "raw": {
129
+ "stdout_path": "raw/stdout.txt",
130
+ "stderr_path": "raw/stderr.txt",
131
+ },
132
+ }
133
+ write_json(output_dir / "summary.json", summary)
134
+ write_solvability_markdown(output_dir / "summary.md", summary)
135
+ primary = {
136
+ "successful": status == "success",
137
+ "all_solved": status == "success",
138
+ "solved": [task["name"] for task in tasks if task.get("solved")],
139
+ "unsolved": [task["name"] for task in tasks if not task.get("solved")],
140
+ "task_statuses": [(task["name"], task["status"]) for task in tasks],
141
+ }
142
+ artifacts = {
143
+ "summary_json": relative_to(output_dir / "summary.json", output_dir),
144
+ "summary_md": relative_to(output_dir / "summary.md", output_dir),
145
+ "raw_stdout": "raw/stdout.txt",
146
+ "raw_stderr": "raw/stderr.txt",
147
+ "output_dir": output_dir.as_posix(),
148
+ }
149
+ return {
150
+ "schema_version": summary["schema_version"],
151
+ "tool": tool,
152
+ "status": status,
153
+ "primary": primary,
154
+ "summary": summary,
155
+ "artifacts": artifacts,
156
+ "items": task_items,
157
+ "tasks": summary["tasks"],
158
+ "summary_path": artifacts["summary_json"],
159
+ "summary_md_path": artifacts["summary_md"],
160
+ "output_dir": output_dir.as_posix(),
161
+ "counts": summary["counts"],
162
+ "by_status": summary["by_status"],
163
+ }
164
+
165
+
166
+ def write_solvability_markdown(path: Path, summary: dict[str, Any]) -> None:
167
+ lines = [
168
+ f"# {summary['tool']}",
169
+ "",
170
+ f"Status: `{summary['status']}`",
171
+ "",
172
+ "## Counts",
173
+ "",
174
+ f"- Tasks: {summary['counts']['tasks']}",
175
+ f"- Solved: {summary['counts']['solved']}",
176
+ f"- Unsolved: {summary['counts']['unsolved']}",
177
+ "",
178
+ "## Tasks",
179
+ "",
180
+ ]
181
+ for item in summary["tasks"]:
182
+ lines.append(f"- `{item['name']}`: `{item['status']}` (`{item['path']}`)")
183
+ with path.open("x", encoding="utf-8") as fh:
184
+ fh.write("\n".join(lines).rstrip() + "\n")
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+
8
+ def _default_workspace_root() -> Path:
9
+ return Path(__file__).resolve().parents[3]
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ServerConfig:
14
+ workspace_root: Path
15
+ output_root: Path
16
+ default_timeout_seconds: float = 600.0
17
+
18
+
19
+ def load_config() -> ServerConfig:
20
+ workspace_root = Path(os.environ.get("PYTYR_MCP_WORKSPACE", _default_workspace_root())).resolve()
21
+ output_root = Path(os.environ.get("PYTYR_MCP_OUTPUT_ROOT", workspace_root / "pytyr-mcp-output")).resolve()
22
+ return ServerConfig(
23
+ workspace_root=workspace_root,
24
+ output_root=output_root,
25
+ default_timeout_seconds=float(os.environ.get("PYTYR_MCP_TIMEOUT", "600")),
26
+ )
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import io
5
+ import json
6
+ from contextlib import redirect_stdout
7
+ from pathlib import Path
8
+ from typing import Any, Callable
9
+
10
+ from pytyr_mcp.planning.sample_generator.results import describe_generator_result, sample_generator_result
11
+ from pytyr_mcp.planning.sample_generator.service import (
12
+ SampleGeneratorOptions,
13
+ describe_make_problem,
14
+ get_generator_path,
15
+ sample_generator,
16
+ )
17
+ from pytyr_mcp.planning.solvability.schemas import ProveSolvabilityOptions
18
+ from pytyr_mcp.planning.solvability.service import prove_solvability
19
+ from pytyr_mcp.roles import load_role
20
+
21
+
22
+ def _sample(args: dict[str, Any]) -> dict[str, Any]:
23
+ result = sample_generator(
24
+ SampleGeneratorOptions(
25
+ domain_name=args["domain_name"],
26
+ output_dir=Path(args["output_dir"]).resolve(),
27
+ batch_name=args.get("batch_name", "sample"),
28
+ configs=args.get("configs", []),
29
+ allow_invalid=bool(args.get("allow_invalid", False)),
30
+ )
31
+ )
32
+ return sample_generator_result(result)
33
+
34
+
35
+ def _describe(args: dict[str, Any]) -> dict[str, Any]:
36
+ domain_name = args["domain_name"]
37
+ return describe_generator_result(
38
+ domain_name=domain_name,
39
+ generator_path=get_generator_path(domain_name),
40
+ signature=describe_make_problem(domain_name),
41
+ )
42
+
43
+
44
+ def _solvability(args: dict[str, Any]) -> dict[str, Any]:
45
+ return prove_solvability(
46
+ ProveSolvabilityOptions(
47
+ domain=args["domain"],
48
+ problem_dir=args["problem_dir"],
49
+ output_dir=args["output_dir"],
50
+ num_threads=int(args.get("num_threads", 1)),
51
+ max_num_states=int(args.get("max_num_states", 100_000)),
52
+ max_time_seconds=float(args.get("max_time_seconds", args.get("max_time", 5.0))),
53
+ include_plans=bool(args.get("include_plans", args.get("print_plan", False))),
54
+ )
55
+ )
56
+
57
+
58
+ TOOLS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
59
+ "tyr.planning.describe_generator": _describe,
60
+ "tyr.planning.sample_generator": _sample,
61
+ "tyr.planning.prove_solvability": _solvability,
62
+ }
63
+
64
+
65
+ def _write_result_json(path: Path, rendered: str) -> None:
66
+ path.parent.mkdir(parents=True, exist_ok=True)
67
+ with path.open("x", encoding="utf-8") as fh:
68
+ fh.write(rendered)
69
+
70
+
71
+ def _ensure_tool_allowed(tool_name: str) -> None:
72
+ role = load_role()
73
+ if role.allows(tool_name):
74
+ return
75
+ allowed = ", ".join(sorted(role.allowed_tools))
76
+ raise PermissionError(
77
+ f"{tool_name} is not allowed for PYTYR_MCP_ROLE={role.name}; "
78
+ f"allowed tools: {allowed}"
79
+ )
80
+
81
+
82
+ def main() -> None:
83
+ parser = argparse.ArgumentParser(description="Invoke a pytyr-mcp tool with JSON arguments.")
84
+ parser.add_argument("tool", choices=sorted(TOOLS))
85
+ parser.add_argument("--args-json", required=True)
86
+ parser.add_argument("--result-json")
87
+ parsed = parser.parse_args()
88
+ try:
89
+ _ensure_tool_allowed(parsed.tool)
90
+ except (PermissionError, ValueError) as exc:
91
+ parser.error(str(exc))
92
+ args = json.loads(parsed.args_json)
93
+ if not isinstance(args, dict):
94
+ raise TypeError("--args-json must decode to an object")
95
+ captured_stdout = io.StringIO()
96
+ with redirect_stdout(captured_stdout):
97
+ result = TOOLS[parsed.tool](args)
98
+ tool_stdout = captured_stdout.getvalue()
99
+ if tool_stdout:
100
+ result = {**result, "_tool_stdout": tool_stdout}
101
+ rendered = json.dumps(result, indent=2, sort_keys=True) + "\n"
102
+ if parsed.result_json:
103
+ result_path = Path(parsed.result_json).resolve()
104
+ _write_result_json(result_path, rendered)
105
+ else:
106
+ print(rendered, end="")
107
+
108
+
109
+ if __name__ == "__main__":
110
+ main()
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def server_output_dir(output_root: Path, output_dir: str | Path) -> Path:
7
+ root = output_root.resolve()
8
+ requested = Path(output_dir)
9
+ resolved = requested.resolve() if requested.is_absolute() else (root / requested).resolve()
10
+ try:
11
+ resolved.relative_to(root)
12
+ except ValueError as exc:
13
+ raise ValueError(f"output_dir must resolve under configured output_root: {root}") from exc
14
+ return resolved
15
+
16
+
17
+ def relative_to(path: Path, root: Path) -> str:
18
+ try:
19
+ return path.relative_to(root).as_posix()
20
+ except ValueError:
21
+ return path.as_posix()
@@ -0,0 +1 @@
1
+ """Planning tools."""