agent-portal 0.0.2__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,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-portal
3
+ Version: 0.0.2
4
+ Summary: Python runtime package for Agent Portal.
5
+ Author: Magnexis
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-Expression: MIT
9
+ Requires-Dist: playwright>=1.54.0
10
+ Project-URL: Homepage, https://github.com/magnexis/agent-portal
11
+ Project-URL: Issues, https://github.com/magnexis/agent-portal/issues
12
+ Project-URL: Repository, https://github.com/magnexis/agent-portal.git
13
+
14
+ # Agent Portal Python Runtime
15
+
16
+ This package contains the local Agent Portal runtime.
17
+
18
+ It provides:
19
+
20
+ - the local HTTP runtime server
21
+ - Playwright-backed browser control
22
+ - agent steering and action policy enforcement
23
+ - runtime doctor checks
24
+ - report generation
25
+ - plugin manifest discovery and validation
26
+
27
+ Install locally with:
28
+
29
+ ```bash
30
+ pip install -e ./python
31
+ ```
32
+
@@ -0,0 +1,18 @@
1
+ # Agent Portal Python Runtime
2
+
3
+ This package contains the local Agent Portal runtime.
4
+
5
+ It provides:
6
+
7
+ - the local HTTP runtime server
8
+ - Playwright-backed browser control
9
+ - agent steering and action policy enforcement
10
+ - runtime doctor checks
11
+ - report generation
12
+ - plugin manifest discovery and validation
13
+
14
+ Install locally with:
15
+
16
+ ```bash
17
+ pip install -e ./python
18
+ ```
@@ -0,0 +1,3 @@
1
+ from .runtime import PortalRuntime
2
+
3
+ __all__ = ["PortalRuntime"]
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -0,0 +1,393 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from .exceptions import BrowserOperationError
7
+ from .logging_utils import build_logger
8
+
9
+
10
+ class BrowserController:
11
+ def __init__(self, screenshot_directory: Path, timeout_ms: int = 5_000) -> None:
12
+ self.screenshot_directory = screenshot_directory
13
+ self.timeout_ms = timeout_ms
14
+ self._page = None
15
+ self._browser = None
16
+ self._context = None
17
+ self._playwright = None
18
+ self._console_errors: list[str] = []
19
+ self._network_errors: list[str] = []
20
+ self.logger = build_logger("agent_portal.browser")
21
+
22
+ def available(self) -> bool:
23
+ try:
24
+ import playwright.sync_api # noqa: F401
25
+ return True
26
+ except Exception:
27
+ return False
28
+
29
+ def start(self) -> None:
30
+ if self._page is not None:
31
+ return
32
+
33
+ try:
34
+ from playwright.sync_api import sync_playwright
35
+ except Exception as exc:
36
+ raise BrowserOperationError(
37
+ "Python Playwright is not installed.",
38
+ module="agent_portal.browser",
39
+ likely_cause="The Python runtime dependencies have not been installed.",
40
+ suggested_fix="Run `pip install -e ./python`.",
41
+ can_continue=False,
42
+ ) from exc
43
+
44
+ try:
45
+ self._playwright = sync_playwright().start()
46
+ self._browser = self._playwright.chromium.launch(headless=True)
47
+ self._context = self._browser.new_context()
48
+ self._page = self._context.new_page()
49
+ self._page.set_default_timeout(self.timeout_ms)
50
+ self._page.on("console", self._handle_console_message)
51
+ self._page.on("pageerror", self._handle_page_error)
52
+ self._page.on("requestfailed", self._handle_request_failed)
53
+ self.logger.info("Browser session started")
54
+ except Exception as exc:
55
+ self.stop()
56
+ raise BrowserOperationError(
57
+ "Browser failed to launch because Chromium is not installed or not available.",
58
+ module="agent_portal.browser",
59
+ likely_cause="Playwright Chromium is missing or corrupted.",
60
+ suggested_fix="Run `python -m playwright install chromium`.",
61
+ can_continue=False,
62
+ ) from exc
63
+
64
+ def stop(self) -> None:
65
+ if self._context is not None:
66
+ self._context.close()
67
+ if self._browser is not None:
68
+ self._browser.close()
69
+ if self._playwright is not None:
70
+ self._playwright.stop()
71
+ self._browser = None
72
+ self._context = None
73
+ self._page = None
74
+ self._playwright = None
75
+ self._console_errors = []
76
+ self._network_errors = []
77
+
78
+ def open_url(self, url: str) -> None:
79
+ page = self._require_page()
80
+ try:
81
+ page.goto(url, wait_until="domcontentloaded", timeout=self.timeout_ms)
82
+ page.wait_for_load_state("networkidle", timeout=self.timeout_ms)
83
+ except Exception as exc:
84
+ raise BrowserOperationError(
85
+ f"Failed to open `{url}`.",
86
+ module="agent_portal.browser",
87
+ likely_cause="The page did not finish loading, the dev server is down, or navigation timed out.",
88
+ suggested_fix="Verify the target URL is reachable and increase the browser timeout if the app is slow.",
89
+ can_continue=True,
90
+ ) from exc
91
+
92
+ def close_page(self) -> None:
93
+ self.stop()
94
+
95
+ def refresh(self) -> None:
96
+ page = self._require_page()
97
+ try:
98
+ page.reload(wait_until="domcontentloaded", timeout=self.timeout_ms)
99
+ page.wait_for_load_state("networkidle", timeout=self.timeout_ms)
100
+ except Exception as exc:
101
+ raise BrowserOperationError(
102
+ "Page refresh failed.",
103
+ module="agent_portal.browser",
104
+ likely_cause="The page became unavailable or reload timed out.",
105
+ suggested_fix="Verify the page is reachable and retry the refresh.",
106
+ can_continue=True,
107
+ ) from exc
108
+
109
+ def back(self) -> None:
110
+ page = self._require_page()
111
+ try:
112
+ page.go_back(wait_until="domcontentloaded", timeout=self.timeout_ms)
113
+ except Exception as exc:
114
+ raise BrowserOperationError(
115
+ "Browser back navigation failed.",
116
+ module="agent_portal.browser",
117
+ likely_cause="There is no prior history entry or the navigation failed.",
118
+ suggested_fix="Open a page first or verify browser history before retrying.",
119
+ can_continue=True,
120
+ ) from exc
121
+
122
+ def forward(self) -> None:
123
+ page = self._require_page()
124
+ try:
125
+ page.go_forward(wait_until="domcontentloaded", timeout=self.timeout_ms)
126
+ except Exception as exc:
127
+ raise BrowserOperationError(
128
+ "Browser forward navigation failed.",
129
+ module="agent_portal.browser",
130
+ likely_cause="There is no forward history entry or the navigation failed.",
131
+ suggested_fix="Navigate backward before trying to move forward again.",
132
+ can_continue=True,
133
+ ) from exc
134
+
135
+ def screenshot(self, name: str) -> str:
136
+ page = self._require_page()
137
+ self.screenshot_directory.mkdir(parents=True, exist_ok=True)
138
+ file_path = self.screenshot_directory / f"{name}.png"
139
+ try:
140
+ page.screenshot(path=str(file_path), full_page=True)
141
+ except Exception as exc:
142
+ raise BrowserOperationError(
143
+ "Screenshot capture failed.",
144
+ module="agent_portal.browser",
145
+ likely_cause="The page crashed or the screenshot path is not writable.",
146
+ suggested_fix="Check browser health and filesystem permissions for the screenshot directory.",
147
+ can_continue=True,
148
+ ) from exc
149
+ return str(file_path)
150
+
151
+ def click(self, selector: str) -> None:
152
+ locator = self._resolve_locator(selector)
153
+ self._ensure_actionable(locator, selector)
154
+ try:
155
+ locator.click(timeout=self.timeout_ms)
156
+ except Exception as exc:
157
+ raise BrowserOperationError(
158
+ f"Click failed for `{selector}`.",
159
+ module="agent_portal.browser",
160
+ likely_cause="The element is covered, detached, or the page is still changing.",
161
+ suggested_fix="Wait for the element to stabilize or use a more specific selector.",
162
+ can_continue=True,
163
+ ) from exc
164
+
165
+ def type_text(self, selector: str, value: str) -> None:
166
+ locator = self._resolve_locator(selector)
167
+ self._ensure_actionable(locator, selector)
168
+ try:
169
+ locator.fill(value, timeout=self.timeout_ms)
170
+ except Exception as exc:
171
+ raise BrowserOperationError(
172
+ f"Typing failed for `{selector}`.",
173
+ module="agent_portal.browser",
174
+ likely_cause="The target is read-only, disabled, or not a fillable element.",
175
+ suggested_fix="Use an input selector that resolves to a visible text field.",
176
+ can_continue=True,
177
+ ) from exc
178
+
179
+ def hover(self, selector: str) -> None:
180
+ locator = self._resolve_locator(selector)
181
+ self._ensure_actionable(locator, selector)
182
+ try:
183
+ locator.hover(timeout=self.timeout_ms)
184
+ except Exception as exc:
185
+ raise BrowserOperationError(
186
+ f"Hover failed for `{selector}`.",
187
+ module="agent_portal.browser",
188
+ likely_cause="The element is hidden or no longer attached to the page.",
189
+ suggested_fix="Wait for the element to become visible before hovering.",
190
+ can_continue=True,
191
+ ) from exc
192
+
193
+ def scroll(self, selector: str | None = None) -> None:
194
+ page = self._require_page()
195
+ try:
196
+ if selector:
197
+ self._resolve_locator(selector).scroll_into_view_if_needed(timeout=self.timeout_ms)
198
+ return
199
+ page.mouse.wheel(0, 800)
200
+ except Exception as exc:
201
+ raise BrowserOperationError(
202
+ "Scroll failed.",
203
+ module="agent_portal.browser",
204
+ likely_cause="The page is not interactive or the target element no longer exists.",
205
+ suggested_fix="Retry after the page settles or scroll the main page instead of a stale element.",
206
+ can_continue=True,
207
+ ) from exc
208
+
209
+ def wait(self, selector: str) -> None:
210
+ try:
211
+ self._resolve_locator(selector).wait_for(state="visible", timeout=self.timeout_ms)
212
+ except Exception as exc:
213
+ raise BrowserOperationError(
214
+ f"Wait timed out for `{selector}`.",
215
+ module="agent_portal.browser",
216
+ likely_cause="The element never became visible or the selector is incorrect.",
217
+ suggested_fix="Use a more stable selector or increase the browser timeout for slower pages.",
218
+ can_continue=True,
219
+ ) from exc
220
+
221
+ def inspect(self) -> dict[str, Any]:
222
+ page = self._require_page()
223
+ return {
224
+ "url": page.url,
225
+ "title": page.title(),
226
+ "dom": page.content(),
227
+ "accessibilityTree": self.read_accessibility_tree(),
228
+ "consoleErrors": self.read_console(),
229
+ "networkErrors": self.read_network(),
230
+ }
231
+
232
+ def current_url(self) -> str | None:
233
+ return self._page.url if self._page else None
234
+
235
+ def current_title(self) -> str | None:
236
+ return self._page.title() if self._page else None
237
+
238
+ def read_console(self) -> list[str]:
239
+ return list(self._console_errors)
240
+
241
+ def read_network(self) -> list[str]:
242
+ return list(self._network_errors)
243
+
244
+ def read_dom(self) -> str:
245
+ return self._require_page().content()
246
+
247
+ def read_accessibility_tree(self) -> Any:
248
+ page = self._require_page()
249
+ accessibility = getattr(page, "accessibility", None)
250
+ if accessibility is None:
251
+ return None
252
+ try:
253
+ return accessibility.snapshot()
254
+ except Exception:
255
+ return None
256
+
257
+ def read_text(self, selector: str) -> str | None:
258
+ locator = self._resolve_locator(selector)
259
+ try:
260
+ return locator.text_content(timeout=self.timeout_ms)
261
+ except Exception as exc:
262
+ raise BrowserOperationError(
263
+ f"Failed to read text for `{selector}`.",
264
+ module="agent_portal.browser",
265
+ likely_cause="The target no longer exists or text content could not be resolved.",
266
+ suggested_fix="Target a stable visible element and retry.",
267
+ can_continue=True,
268
+ ) from exc
269
+
270
+ def execute(self, script: str) -> Any:
271
+ page = self._require_page()
272
+ try:
273
+ return page.evaluate(script)
274
+ except Exception as exc:
275
+ raise BrowserOperationError(
276
+ "Script execution failed.",
277
+ module="agent_portal.browser",
278
+ likely_cause="The script threw an error or referenced unavailable browser state.",
279
+ suggested_fix="Validate the script against the current page and retry with a simpler expression.",
280
+ can_continue=True,
281
+ ) from exc
282
+
283
+ def inspect_element(self, selector: str) -> dict[str, Any]:
284
+ locator = self._resolve_locator(selector)
285
+ try:
286
+ text = locator.text_content(timeout=self.timeout_ms)
287
+ html = locator.evaluate("(node) => node.outerHTML")
288
+ is_visible = locator.is_visible()
289
+ return {
290
+ "selector": selector,
291
+ "text": text,
292
+ "html": html,
293
+ "visible": is_visible,
294
+ }
295
+ except Exception as exc:
296
+ raise BrowserOperationError(
297
+ f"Element inspection failed for `{selector}`.",
298
+ module="agent_portal.browser",
299
+ likely_cause="The element became detached or could not be serialized.",
300
+ suggested_fix="Retry with a more specific selector after the page settles.",
301
+ can_continue=True,
302
+ ) from exc
303
+
304
+ def _resolve_locator(self, selector: str):
305
+ page = self._require_page()
306
+ candidates = [selector]
307
+ normalized = selector.lstrip("#")
308
+ candidates.extend(
309
+ [
310
+ f"#{normalized}",
311
+ f"[name='{normalized}']",
312
+ f"[aria-label='{normalized}']",
313
+ f"text={normalized}",
314
+ ]
315
+ )
316
+ for candidate in candidates:
317
+ locator = page.locator(candidate).first
318
+ try:
319
+ if locator.count() > 0:
320
+ return locator
321
+ except Exception:
322
+ continue
323
+ lowered = normalized.lower()
324
+ try:
325
+ role_candidates = [
326
+ page.get_by_role("button", name=normalized).first,
327
+ page.get_by_role("link", name=normalized).first,
328
+ page.get_by_role("textbox", name=normalized).first,
329
+ ]
330
+ for locator in role_candidates:
331
+ if locator.count() > 0:
332
+ return locator
333
+ if lowered.startswith("//"):
334
+ xpath_locator = page.locator(f"xpath={selector}").first
335
+ if xpath_locator.count() > 0:
336
+ return xpath_locator
337
+ except Exception:
338
+ pass
339
+ raise BrowserOperationError(
340
+ f"Element could not be found for selector `{selector}`.",
341
+ module="agent_portal.browser",
342
+ likely_cause="The selector is stale, hidden, or never existed on the page.",
343
+ suggested_fix="Try a more specific selector, aria-label, visible text, or role target.",
344
+ can_continue=True,
345
+ )
346
+
347
+ def _ensure_actionable(self, locator: Any, selector: str) -> None:
348
+ try:
349
+ locator.wait_for(state="visible", timeout=self.timeout_ms)
350
+ if locator.is_disabled():
351
+ raise BrowserOperationError(
352
+ f"Element `{selector}` is disabled.",
353
+ module="agent_portal.browser",
354
+ likely_cause="The control is present but not interactive yet.",
355
+ suggested_fix="Wait for the app to finish loading or target an enabled element.",
356
+ can_continue=True,
357
+ )
358
+ except BrowserOperationError:
359
+ raise
360
+ except Exception as exc:
361
+ raise BrowserOperationError(
362
+ f"Element `{selector}` is not actionable.",
363
+ module="agent_portal.browser",
364
+ likely_cause="The target is hidden, detached, or blocked by a modal.",
365
+ suggested_fix="Retry after the page settles or target a visible element inside the active dialog.",
366
+ can_continue=True,
367
+ ) from exc
368
+
369
+ def _require_page(self):
370
+ if self._page is None:
371
+ raise BrowserOperationError(
372
+ "Browser session is not ready.",
373
+ module="agent_portal.browser",
374
+ likely_cause="The runtime has not launched the browser yet.",
375
+ suggested_fix="Start the runtime and open a page before issuing browser actions.",
376
+ can_continue=False,
377
+ )
378
+ return self._page
379
+
380
+ def _handle_console_message(self, message: Any) -> None:
381
+ if getattr(message, "type", "") == "error":
382
+ self._console_errors.append(message.text)
383
+ self._console_errors = self._console_errors[-20:]
384
+
385
+ def _handle_page_error(self, error: Any) -> None:
386
+ self._console_errors.append(str(error))
387
+ self._console_errors = self._console_errors[-20:]
388
+
389
+ def _handle_request_failed(self, request: Any) -> None:
390
+ failure = request.failure
391
+ error_text = failure["errorText"] if isinstance(failure, dict) else "unknown failure"
392
+ self._network_errors.append(f"{request.method} {request.url} - {error_text}")
393
+ self._network_errors = self._network_errors[-20:]
@@ -0,0 +1,164 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from dataclasses import asdict
6
+ from pathlib import Path
7
+ import sys
8
+ from urllib.error import HTTPError, URLError
9
+ from urllib.request import Request, urlopen
10
+
11
+ from .config import load_config, save_default_config
12
+ from .doctor import run_doctor
13
+ from .plugin_system import discover_plugins, validate_plugin_manifest
14
+ from .runtime import PortalRuntime
15
+ from .server import serve
16
+
17
+
18
+ def main() -> None:
19
+ parser = build_parser()
20
+ args = parser.parse_args()
21
+ workspace = Path.cwd()
22
+ save_default_config(workspace)
23
+ config = load_config(workspace)
24
+ host = args.host or config.runtime_host
25
+ port = args.port or config.runtime_port
26
+ runtime_url = f"http://{host}:{port}"
27
+
28
+ config.runtime_host = host
29
+ config.runtime_port = port
30
+
31
+ try:
32
+ if args.command == "start":
33
+ serve(PortalRuntime(workspace, config))
34
+ return
35
+ if args.command == "stop":
36
+ print_json_or_text(post_json(f"{runtime_url}/control/stop"), args.json)
37
+ return
38
+ if args.command == "status":
39
+ print_json_or_text(get_json(f"{runtime_url}/status"), args.json)
40
+ return
41
+ if args.command == "doctor":
42
+ report = run_doctor(workspace)
43
+ payload = {"checks": [asdict(check) for check in report.checks]}
44
+ print_json_or_text(payload, args.json)
45
+ return
46
+ if args.command == "open":
47
+ payload = {"url": args.url}
48
+ print_json_or_text(post_json(f"{runtime_url}/browser/open", payload), args.json)
49
+ return
50
+ if args.command == "screenshot":
51
+ payload = {"label": args.label}
52
+ print_json_or_text(post_json(f"{runtime_url}/browser/screenshot", payload), args.json)
53
+ return
54
+ if args.command == "report":
55
+ print_json_or_text(post_json(f"{runtime_url}/report/generate"), args.json)
56
+ return
57
+ if args.command == "plugins":
58
+ if args.plugins_command == "list":
59
+ payload = [str(path) for path in discover_plugins(workspace)]
60
+ print_json_or_text(payload, args.json)
61
+ return
62
+ if args.plugins_command == "validate":
63
+ results = {
64
+ str(path): validate_plugin_manifest(path)
65
+ for path in discover_plugins(workspace)
66
+ }
67
+ print_json_or_text(results, args.json)
68
+ return
69
+ if args.command == "mcp":
70
+ mcp_cli = load_mcp_cli_module()
71
+ argv = [args.mcp_command]
72
+ if args.host or args.port:
73
+ argv.extend(["--runtime-url", runtime_url])
74
+ if args.json:
75
+ argv.append("--json")
76
+ old_argv = sys.argv[:]
77
+ try:
78
+ sys.argv = ["agent-portal-mcp", *argv]
79
+ mcp_cli.main()
80
+ finally:
81
+ sys.argv = old_argv
82
+ return
83
+ except (HTTPError, URLError) as exc:
84
+ print_json_or_text(
85
+ {
86
+ "error": "Runtime request failed",
87
+ "details": str(exc),
88
+ "suggestedFix": f"Start the runtime with `agent-portal --host {host} --port {port} start`.",
89
+ },
90
+ True if args.json else False,
91
+ )
92
+ raise SystemExit(1) from exc
93
+
94
+
95
+ def build_parser() -> argparse.ArgumentParser:
96
+ parser = argparse.ArgumentParser(prog="agent-portal")
97
+ parser.add_argument("--json", action="store_true")
98
+ parser.add_argument("--verbose", action="store_true")
99
+ parser.add_argument("--debug", action="store_true")
100
+ parser.add_argument("--host")
101
+ parser.add_argument("--port", type=int)
102
+ parser.add_argument("--profile")
103
+
104
+ subparsers = parser.add_subparsers(dest="command", required=True)
105
+ subparsers.add_parser("start")
106
+ subparsers.add_parser("stop")
107
+ subparsers.add_parser("status")
108
+ subparsers.add_parser("doctor")
109
+
110
+ open_parser = subparsers.add_parser("open")
111
+ open_parser.add_argument("url")
112
+
113
+ screenshot_parser = subparsers.add_parser("screenshot")
114
+ screenshot_parser.add_argument("--label", default="manual")
115
+
116
+ subparsers.add_parser("report")
117
+
118
+ plugins_parser = subparsers.add_parser("plugins")
119
+ plugins_subparsers = plugins_parser.add_subparsers(dest="plugins_command", required=True)
120
+ plugins_subparsers.add_parser("list")
121
+ plugins_subparsers.add_parser("validate")
122
+
123
+ mcp_parser = subparsers.add_parser("mcp")
124
+ mcp_subparsers = mcp_parser.add_subparsers(dest="mcp_command", required=True)
125
+ mcp_subparsers.add_parser("start")
126
+ mcp_subparsers.add_parser("doctor")
127
+ return parser
128
+
129
+
130
+ def get_json(url: str) -> object:
131
+ with urlopen(url) as response:
132
+ return json.loads(response.read().decode("utf8"))
133
+
134
+
135
+ def post_json(url: str, payload: dict[str, object] | None = None) -> object:
136
+ data = json.dumps(payload or {}).encode("utf8")
137
+ request = Request(url, data=data, headers={"Content-Type": "application/json"})
138
+ with urlopen(request) as response:
139
+ return json.loads(response.read().decode("utf8"))
140
+
141
+
142
+ def print_json_or_text(payload: object, json_output: bool) -> None:
143
+ if json_output:
144
+ print(json.dumps(payload, indent=2))
145
+ return
146
+ if isinstance(payload, dict):
147
+ for key, value in payload.items():
148
+ print(f"{key}: {value}")
149
+ return
150
+ if isinstance(payload, list):
151
+ for entry in payload:
152
+ print(f"- {entry}")
153
+ return
154
+ print(payload)
155
+
156
+
157
+ def load_mcp_cli_module():
158
+ repo_root = Path(__file__).resolve().parents[2]
159
+ mcp_src = repo_root / "packages" / "agent-portal-mcp"
160
+ if str(mcp_src) not in sys.path:
161
+ sys.path.insert(0, str(mcp_src))
162
+ from agent_portal_mcp import cli as mcp_cli # type: ignore
163
+
164
+ return mcp_cli
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import asdict
5
+ from pathlib import Path
6
+
7
+ from .models import RuntimeConfigModel
8
+
9
+
10
+ DEFAULT_CONFIG_PATH = "agent-portal.config.json"
11
+
12
+
13
+ def load_config(base_path: Path | None = None) -> RuntimeConfigModel:
14
+ root = base_path or Path.cwd()
15
+ config_path = root / DEFAULT_CONFIG_PATH
16
+ if not config_path.exists():
17
+ return RuntimeConfigModel()
18
+
19
+ raw = json.loads(config_path.read_text(encoding="utf8"))
20
+ return RuntimeConfigModel(**raw)
21
+
22
+
23
+ def save_default_config(base_path: Path | None = None) -> Path:
24
+ root = base_path or Path.cwd()
25
+ config_path = root / DEFAULT_CONFIG_PATH
26
+ if not config_path.exists():
27
+ config_path.write_text(
28
+ json.dumps(asdict(RuntimeConfigModel()), indent=2),
29
+ encoding="utf8",
30
+ )
31
+ return config_path