prizmkit 1.1.108 → 1.1.109

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 (39) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/dev-pipeline/prizmkit_runtime/cli.py +20 -0
  3. package/bundled/dev-pipeline/prizmkit_runtime/commands.py +1 -0
  4. package/bundled/dev-pipeline/prizmkit_runtime/gitops.py +21 -0
  5. package/bundled/dev-pipeline/prizmkit_runtime/interoperability.py +1 -0
  6. package/bundled/dev-pipeline/prizmkit_runtime/platform_detection.py +208 -0
  7. package/bundled/dev-pipeline/prizmkit_runtime/runtime_helper.py +508 -0
  8. package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +3 -1
  9. package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +3 -1
  10. package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +3 -1
  11. package/bundled/dev-pipeline/scripts/prizmkit-runtime-helper.py +27 -0
  12. package/bundled/dev-pipeline/scripts/utils.py +48 -77
  13. package/bundled/dev-pipeline/templates/bootstrap-tier1.md +33 -60
  14. package/bundled/dev-pipeline/templates/bootstrap-tier2.md +35 -62
  15. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +40 -66
  16. package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +2 -2
  17. package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +2 -2
  18. package/bundled/dev-pipeline/templates/sections/checkpoint-system.md +1 -1
  19. package/bundled/dev-pipeline/templates/sections/phase-browser-verification-auto.md +31 -54
  20. package/bundled/dev-pipeline/templates/sections/phase-browser-verification-opencli.md +21 -30
  21. package/bundled/dev-pipeline/templates/sections/phase-browser-verification.md +22 -62
  22. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-base.md +1 -1
  23. package/bundled/dev-pipeline/templates/sections/phase-critic-plan-full.md +1 -1
  24. package/bundled/dev-pipeline/templates/sections/phase-critic-plan.md +1 -1
  25. package/bundled/dev-pipeline/templates/sections/phase-plan-agent.md +1 -1
  26. package/bundled/dev-pipeline/templates/sections/phase-plan-lite.md +1 -1
  27. package/bundled/dev-pipeline/templates/sections/phase-prizmkit-test.md +2 -2
  28. package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +1 -1
  29. package/bundled/dev-pipeline/templates/sections/phase-review-full.md +1 -1
  30. package/bundled/dev-pipeline/templates/sections/phase-specify-plan-full.md +7 -6
  31. package/bundled/dev-pipeline/templates/sections/phase0-init.md +1 -1
  32. package/bundled/dev-pipeline/templates/sections/subagent-timeout-recovery.md +1 -1
  33. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +45 -3
  34. package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +2 -1
  35. package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +2 -1
  36. package/bundled/dev-pipeline/tests/test_runtime_helper.py +211 -0
  37. package/bundled/dev-pipeline/tests/test_unified_cli.py +5 -0
  38. package/bundled/skills/_metadata.json +1 -1
  39. package/package.json +1 -1
@@ -0,0 +1,508 @@
1
+ """Cross-platform helper CLI for prompt runtime operations.
2
+
3
+ The helpers in this module use Python standard-library APIs for file checks,
4
+ source discovery, text search, platform detection, executable checks, port
5
+ readiness, process cleanup, and lightweight browser-readiness polling. Stdout
6
+ starts with deterministic markers so generated AI prompts can branch on text
7
+ instead of shell exit-code chains.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import os
15
+ import shutil
16
+ import socket
17
+ import subprocess
18
+ import sys
19
+ import time
20
+ import urllib.error
21
+ import urllib.request
22
+ from dataclasses import asdict, dataclass
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from .paths import resolve_runtime_paths
27
+ from .platform_detection import resolve_platform, resolve_skill
28
+ from .processes import is_pid_running, terminate_pid_tree
29
+
30
+ SOURCE_EXTENSIONS = (
31
+ ".js",
32
+ ".jsx",
33
+ ".ts",
34
+ ".tsx",
35
+ ".mjs",
36
+ ".cjs",
37
+ ".py",
38
+ ".go",
39
+ ".rs",
40
+ ".java",
41
+ ".kt",
42
+ ".rb",
43
+ ".php",
44
+ ".cs",
45
+ ".swift",
46
+ ".sh",
47
+ ".ps1",
48
+ ".md",
49
+ ".json",
50
+ ".yaml",
51
+ ".yml",
52
+ ".toml",
53
+ )
54
+
55
+ DEFAULT_EXCLUDES = (
56
+ ".git",
57
+ "node_modules",
58
+ "__pycache__",
59
+ ".pytest_cache",
60
+ ".mypy_cache",
61
+ "dist",
62
+ "build",
63
+ "vendor",
64
+ )
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class HelperResult:
69
+ """Structured helper result rendered as marker text or JSON."""
70
+
71
+ marker: str
72
+ ok: bool
73
+ fields: dict[str, Any]
74
+ exit_code: int = 0
75
+
76
+
77
+ def _path(value: str | Path) -> Path:
78
+ return Path(value).expanduser().resolve()
79
+
80
+
81
+ def _relative(path: Path, root: Path) -> str:
82
+ try:
83
+ return path.relative_to(root).as_posix()
84
+ except ValueError:
85
+ return path.as_posix()
86
+
87
+
88
+ def render_result(result: HelperResult, *, json_output: bool = False) -> str:
89
+ """Render a helper result without writing to stdout."""
90
+ if json_output:
91
+ payload = {"marker": result.marker, "ok": result.ok, **result.fields}
92
+ return json.dumps(payload, ensure_ascii=False, sort_keys=True)
93
+
94
+ lines = [result.marker]
95
+ for key, value in result.fields.items():
96
+ if value is None:
97
+ continue
98
+ if isinstance(value, (list, tuple)):
99
+ for item in value:
100
+ lines.append(f"{key}: {item}")
101
+ elif isinstance(value, dict):
102
+ lines.append(f"{key}: {json.dumps(value, ensure_ascii=False, sort_keys=True)}")
103
+ else:
104
+ lines.append(f"{key}: {value}")
105
+ return "\n".join(lines)
106
+
107
+
108
+ def _emit(result: HelperResult, *, json_output: bool = False) -> int:
109
+ print(render_result(result, json_output=json_output))
110
+ return result.exit_code
111
+
112
+
113
+ def _add_json(parser: argparse.ArgumentParser) -> None:
114
+ parser.add_argument("--json", action="store_true", help="Emit a single JSON object instead of marker text.")
115
+
116
+
117
+ def _iter_files(root: Path, *, excludes: tuple[str, ...] = DEFAULT_EXCLUDES):
118
+ if root.is_file():
119
+ yield root
120
+ return
121
+ if not root.is_dir():
122
+ return
123
+ for current_root, dir_names, file_names in os.walk(root):
124
+ dir_names[:] = sorted(name for name in dir_names if name not in excludes)
125
+ for file_name in sorted(file_names):
126
+ yield Path(current_root) / file_name
127
+
128
+
129
+ def _artifact_exists(args: argparse.Namespace) -> HelperResult:
130
+ target = _path(args.path)
131
+ exists = target.exists()
132
+ exists_marker = getattr(args, "exists_marker", "EXISTS")
133
+ missing_marker = getattr(args, "missing_marker", "MISSING")
134
+ return HelperResult(exists_marker if exists else missing_marker, exists, {"path": str(target), "type": "dir" if target.is_dir() else "file" if target.is_file() else "missing"})
135
+
136
+
137
+ def _artifact_ensure_dir(args: argparse.Namespace) -> HelperResult:
138
+ target = _path(args.path)
139
+ target.mkdir(parents=True, exist_ok=True)
140
+ return HelperResult("DIR_READY", True, {"path": str(target)})
141
+
142
+
143
+ def _artifact_touch(args: argparse.Namespace) -> HelperResult:
144
+ target = _path(args.path)
145
+ target.parent.mkdir(parents=True, exist_ok=True)
146
+ target.touch(exist_ok=True)
147
+ return HelperResult("TOUCHED", True, {"path": str(target)})
148
+
149
+
150
+ def _artifact_list(args: argparse.Namespace) -> HelperResult:
151
+ target = _path(args.path)
152
+ if not target.is_dir():
153
+ return HelperResult("MISSING", False, {"path": str(target), "entries": []}, 1)
154
+ entries = []
155
+ for child in sorted(target.iterdir(), key=lambda item: item.name):
156
+ entries.append({"name": child.name, "type": "dir" if child.is_dir() else "file" if child.is_file() else "other"})
157
+ if args.json:
158
+ return HelperResult("LIST", True, {"path": str(target), "entries": entries})
159
+ lines = [f"{entry['type']}:{entry['name']}" for entry in entries]
160
+ return HelperResult("LIST", True, {"path": str(target), "entry": lines})
161
+
162
+
163
+ def _artifact_tree(args: argparse.Namespace) -> HelperResult:
164
+ root = _path(args.path)
165
+ if not root.exists():
166
+ return HelperResult("MISSING", False, {"path": str(root), "entries": []}, 1)
167
+ max_depth = max(0, int(args.max_depth))
168
+ entries: list[str] = []
169
+ if root.is_file():
170
+ entries.append(root.name)
171
+ else:
172
+ for current_root, dir_names, file_names in os.walk(root):
173
+ current = Path(current_root)
174
+ depth = len(current.relative_to(root).parts) if current != root else 0
175
+ if depth >= max_depth:
176
+ dir_names[:] = []
177
+ dir_names[:] = sorted(name for name in dir_names if name not in DEFAULT_EXCLUDES)
178
+ for directory in dir_names:
179
+ entries.append(f"{_relative(current / directory, root)}/")
180
+ for file_name in sorted(file_names):
181
+ entries.append(_relative(current / file_name, root))
182
+ return HelperResult("TREE", True, {"path": str(root), "entries": entries} if args.json else {"path": str(root), "entry": entries})
183
+
184
+
185
+ def _artifact_source_files(args: argparse.Namespace) -> HelperResult:
186
+ root = _path(args.path)
187
+ extensions = tuple(args.ext or SOURCE_EXTENSIONS)
188
+ files = [
189
+ _relative(file_path, root if root.is_dir() else root.parent)
190
+ for file_path in _iter_files(root)
191
+ if file_path.suffix in extensions
192
+ ]
193
+ return HelperResult("SOURCE_FILES", True, {"path": str(root), "files": files} if args.json else {"path": str(root), "file": files})
194
+
195
+
196
+ def _text_search(args: argparse.Namespace) -> HelperResult:
197
+ target = _path(args.path)
198
+ pattern = args.pattern
199
+ count = 0
200
+ matches: list[str] = []
201
+ for file_path in _iter_files(target):
202
+ try:
203
+ text = file_path.read_text(encoding="utf-8", errors="ignore")
204
+ except OSError:
205
+ continue
206
+ file_count = text.count(pattern)
207
+ if file_count:
208
+ count += file_count
209
+ matches.append(_relative(file_path, target if target.is_dir() else target.parent))
210
+ found = count > 0
211
+ return HelperResult("FOUND" if found else "NOT_FOUND", found, {"count": count, "match": matches})
212
+
213
+
214
+ def _text_count(args: argparse.Namespace) -> HelperResult:
215
+ target = _path(args.path)
216
+ count = 0
217
+ for file_path in _iter_files(target):
218
+ try:
219
+ count += file_path.read_text(encoding="utf-8", errors="ignore").count(args.pattern)
220
+ except OSError:
221
+ continue
222
+ return HelperResult("COUNT", True, {"count": count})
223
+
224
+
225
+ def _text_contains(args: argparse.Namespace) -> HelperResult:
226
+ target = _path(args.path)
227
+ if not target.is_file():
228
+ return HelperResult("GATE:MISSING", False, {"path": str(target), "reason": "file_missing"}, 1)
229
+ try:
230
+ found = args.pattern in target.read_text(encoding="utf-8", errors="ignore")
231
+ except OSError as exc:
232
+ return HelperResult("GATE:MISSING", False, {"path": str(target), "reason": type(exc).__name__}, 1)
233
+ return HelperResult(args.found_marker if found else args.missing_marker, found, {"path": str(target), "pattern": args.pattern})
234
+
235
+
236
+ def _platform_detect(args: argparse.Namespace) -> HelperResult:
237
+ paths = resolve_runtime_paths(project_root=args.project_root, pipeline_root=args.pipeline_root)
238
+ resolution = resolve_platform(paths.project_root)
239
+ marker = resolution.platform.upper() if resolution.platform else "NOT_INSTALLED"
240
+ return HelperResult(marker, bool(resolution.platform), {"platform": resolution.platform, "source": resolution.source, "notes": list(resolution.notes)})
241
+
242
+
243
+ def _platform_skill(args: argparse.Namespace) -> HelperResult:
244
+ paths = resolve_runtime_paths(project_root=args.project_root, pipeline_root=args.pipeline_root)
245
+ resolution = resolve_skill(paths.project_root, args.skill, platform=args.platform, prefix=args.prefix)
246
+ fields = {
247
+ "platform": resolution.platform,
248
+ "skill": resolution.skill,
249
+ "path": str(resolution.path) if resolution.path else None,
250
+ "candidates": [asdict(candidate) | {"path": str(candidate.path)} for candidate in resolution.candidates],
251
+ }
252
+ return HelperResult(resolution.marker, resolution.marker == "SKILL_EXISTS", fields)
253
+
254
+
255
+ def _executable_check(args: argparse.Namespace) -> HelperResult:
256
+ executable = shutil.which(args.name)
257
+ return HelperResult("INSTALLED" if executable else "NOT_INSTALLED", bool(executable), {"name": args.name, "path": executable})
258
+
259
+
260
+ def _executable_version(args: argparse.Namespace) -> HelperResult:
261
+ executable = shutil.which(args.name)
262
+ if not executable:
263
+ return HelperResult("NOT_INSTALLED", False, {"name": args.name}, 1)
264
+ command = [executable, *(args.arg or ["--version"])]
265
+ try:
266
+ completed = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=args.timeout, check=False)
267
+ except (OSError, subprocess.TimeoutExpired) as exc:
268
+ return HelperResult("VERSION_ERROR", False, {"name": args.name, "error": type(exc).__name__}, 1)
269
+ output = (completed.stdout or completed.stderr or "").strip().splitlines()
270
+ return HelperResult("VERSION", completed.returncode == 0, {"name": args.name, "path": executable, "version": output[0] if output else "", "return_code": completed.returncode}, completed.returncode)
271
+
272
+
273
+ def _port_check(args: argparse.Namespace) -> HelperResult:
274
+ port = int(args.port)
275
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
276
+ try:
277
+ sock.settimeout(float(args.timeout))
278
+ in_use = sock.connect_ex((args.host, port)) == 0
279
+ finally:
280
+ sock.close()
281
+ return HelperResult("PORT_IN_USE" if in_use else "PORT_FREE", not in_use, {"host": args.host, "port": port})
282
+
283
+
284
+ def _process_status_pid(args: argparse.Namespace) -> HelperResult:
285
+ pid = int(args.pid)
286
+ running = is_pid_running(pid)
287
+ return HelperResult("PROCESS_RUNNING" if running else "PROCESS_NOT_RUNNING", running, {"pid": pid})
288
+
289
+
290
+ def _process_cleanup_pid(args: argparse.Namespace) -> HelperResult:
291
+ pid = int(args.pid)
292
+ result = terminate_pid_tree(pid, "runtime_helper_cleanup", grace_seconds=float(args.grace_seconds))
293
+ marker = "PROCESS_TERMINATED" if result.terminated else "PROCESS_NOT_RUNNING" if not is_pid_running(pid) else "PROCESS_CLEANUP_FAILED"
294
+ return HelperResult(marker, marker != "PROCESS_CLEANUP_FAILED", {"pid": pid, "forced": result.forced, "details": list(result.details)})
295
+
296
+
297
+ def _process_cleanup_port(args: argparse.Namespace) -> HelperResult:
298
+ # The Python standard library cannot portably map listening ports to owning
299
+ # PIDs on all supported platforms without shelling out to lsof/netstat/taskkill.
300
+ # Keep this deterministic and safe instead of pretending cleanup happened.
301
+ port_result = _port_check(argparse.Namespace(port=args.port, host=args.host, timeout=args.timeout))
302
+ marker = "PORT_FREE" if port_result.marker == "PORT_FREE" else "UNSUPPORTED"
303
+ ok = marker == "PORT_FREE"
304
+ return HelperResult(marker, ok, {"host": args.host, "port": int(args.port), "reason": "pid_lookup_by_port_not_available_in_python_stdlib"}, 0 if ok else 2)
305
+
306
+
307
+ def _process_start(args: argparse.Namespace) -> HelperResult:
308
+ command = list(args.command)
309
+ if command and command[0] == "--":
310
+ command = command[1:]
311
+ if not command:
312
+ return HelperResult("PROCESS_START_FAILED", False, {"reason": "missing_command"}, 2)
313
+ cwd = _path(args.cwd or ".")
314
+ stdout_target = subprocess.DEVNULL if not args.stdout else open(_path(args.stdout), "a", encoding="utf-8")
315
+ stderr_target = subprocess.DEVNULL if not args.stderr else open(_path(args.stderr), "a", encoding="utf-8")
316
+ try:
317
+ process = subprocess.Popen(
318
+ [str(part) for part in command],
319
+ cwd=cwd,
320
+ stdout=stdout_target,
321
+ stderr=stderr_target,
322
+ stdin=subprocess.DEVNULL,
323
+ start_new_session=(os.name != "nt"),
324
+ creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if os.name == "nt" else 0,
325
+ )
326
+ except OSError as exc:
327
+ return HelperResult("PROCESS_START_FAILED", False, {"reason": type(exc).__name__, "error": str(exc)}, 1)
328
+ finally:
329
+ for target in (stdout_target, stderr_target):
330
+ if hasattr(target, "close"):
331
+ target.close()
332
+ if args.pid_file:
333
+ pid_file = _path(args.pid_file)
334
+ pid_file.parent.mkdir(parents=True, exist_ok=True)
335
+ pid_file.write_text(f"{process.pid}\n", encoding="utf-8")
336
+ return HelperResult("PROCESS_STARTED", True, {"pid": process.pid, "pid_file": str(_path(args.pid_file)) if args.pid_file else None})
337
+
338
+
339
+ def _web_wait_ready(args: argparse.Namespace) -> HelperResult:
340
+ deadline = time.monotonic() + float(args.timeout)
341
+ statuses: list[str] = []
342
+ while time.monotonic() <= deadline:
343
+ try:
344
+ request = urllib.request.Request(args.url, method="GET")
345
+ with urllib.request.urlopen(request, timeout=float(args.request_timeout)) as response:
346
+ status = int(response.status)
347
+ statuses.append(str(status))
348
+ if status in args.status:
349
+ return HelperResult("READY", True, {"url": args.url, "status": status})
350
+ except urllib.error.HTTPError as exc:
351
+ statuses.append(str(exc.code))
352
+ if int(exc.code) in args.status:
353
+ return HelperResult("READY", True, {"url": args.url, "status": int(exc.code)})
354
+ except (OSError, urllib.error.URLError, TimeoutError) as exc:
355
+ statuses.append(type(exc).__name__)
356
+ time.sleep(float(args.interval))
357
+ return HelperResult("NOT_READY", False, {"url": args.url, "observed": statuses[-5:]}, 1)
358
+
359
+
360
+ def build_parser() -> argparse.ArgumentParser:
361
+ parser = argparse.ArgumentParser(prog="prizmkit-runtime-helper", description="Cross-platform PrizmKit prompt runtime helper CLI.")
362
+ subparsers = parser.add_subparsers(dest="group", required=True)
363
+
364
+ artifact = subparsers.add_parser("artifact", help="Artifact and source discovery helpers")
365
+ artifact_sub = artifact.add_subparsers(dest="action", required=True)
366
+ exists = artifact_sub.add_parser("exists", help="Check whether a path exists")
367
+ exists.add_argument("path")
368
+ exists.add_argument("--exists-marker", default="EXISTS")
369
+ exists.add_argument("--missing-marker", default="MISSING")
370
+ _add_json(exists)
371
+ exists.set_defaults(func=_artifact_exists)
372
+ ensure_dir = artifact_sub.add_parser("ensure-dir", help="Create a directory if missing")
373
+ ensure_dir.add_argument("path")
374
+ _add_json(ensure_dir)
375
+ ensure_dir.set_defaults(func=_artifact_ensure_dir)
376
+ touch = artifact_sub.add_parser("touch", help="Create a file and parent directories if missing")
377
+ touch.add_argument("path")
378
+ _add_json(touch)
379
+ touch.set_defaults(func=_artifact_touch)
380
+ list_parser = artifact_sub.add_parser("list", help="List a directory deterministically")
381
+ list_parser.add_argument("path")
382
+ _add_json(list_parser)
383
+ list_parser.set_defaults(func=_artifact_list)
384
+ tree = artifact_sub.add_parser("tree", help="Render a deterministic tree")
385
+ tree.add_argument("path")
386
+ tree.add_argument("--max-depth", type=int, default=3)
387
+ _add_json(tree)
388
+ tree.set_defaults(func=_artifact_tree)
389
+ source = artifact_sub.add_parser("source-files", help="Discover source files")
390
+ source.add_argument("path")
391
+ source.add_argument("--ext", action="append", help="Extension to include, e.g. .py")
392
+ _add_json(source)
393
+ source.set_defaults(func=_artifact_source_files)
394
+
395
+ text = subparsers.add_parser("text", help="Text search helpers")
396
+ text_sub = text.add_subparsers(dest="action", required=True)
397
+ search = text_sub.add_parser("search", help="Search text under a path")
398
+ search.add_argument("path")
399
+ search.add_argument("--pattern", required=True)
400
+ _add_json(search)
401
+ search.set_defaults(func=_text_search)
402
+ count = text_sub.add_parser("count", help="Count text occurrences under a path")
403
+ count.add_argument("path")
404
+ count.add_argument("--pattern", required=True)
405
+ _add_json(count)
406
+ count.set_defaults(func=_text_count)
407
+ contains = text_sub.add_parser("contains", help="Check one file contains text and emit gate markers")
408
+ contains.add_argument("path")
409
+ contains.add_argument("--pattern", required=True)
410
+ contains.add_argument("--found-marker", default="GATE:PASS")
411
+ contains.add_argument("--missing-marker", default="GATE:MISSING")
412
+ _add_json(contains)
413
+ contains.set_defaults(func=_text_contains)
414
+
415
+ platform = subparsers.add_parser("platform", help="AI platform and skill detection")
416
+ platform_sub = platform.add_subparsers(dest="action", required=True)
417
+ detect = platform_sub.add_parser("detect", help="Detect current AI platform")
418
+ detect.add_argument("--project-root")
419
+ detect.add_argument("--pipeline-root")
420
+ _add_json(detect)
421
+ detect.set_defaults(func=_platform_detect)
422
+ skill = platform_sub.add_parser("skill", help="Detect a platform skill")
423
+ skill.add_argument("skill")
424
+ skill.add_argument("--platform", default="auto")
425
+ skill.add_argument("--project-root")
426
+ skill.add_argument("--pipeline-root")
427
+ skill.add_argument("--prefix", action="store_true")
428
+ _add_json(skill)
429
+ skill.set_defaults(func=_platform_skill)
430
+
431
+ executable = subparsers.add_parser("executable", help="Executable checks")
432
+ executable_sub = executable.add_subparsers(dest="action", required=True)
433
+ check = executable_sub.add_parser("check", help="Check executable availability")
434
+ check.add_argument("name")
435
+ _add_json(check)
436
+ check.set_defaults(func=_executable_check)
437
+ version = executable_sub.add_parser("version", help="Run executable version command")
438
+ version.add_argument("name")
439
+ version.add_argument("--arg", action="append", default=None)
440
+ version.add_argument("--timeout", type=float, default=10.0)
441
+ _add_json(version)
442
+ version.set_defaults(func=_executable_version)
443
+
444
+ port = subparsers.add_parser("port", help="Port readiness helpers")
445
+ port_sub = port.add_subparsers(dest="action", required=True)
446
+ port_check = port_sub.add_parser("check", help="Check whether a TCP port is in use")
447
+ port_check.add_argument("port", type=int)
448
+ port_check.add_argument("--host", default="127.0.0.1")
449
+ port_check.add_argument("--timeout", type=float, default=0.5)
450
+ _add_json(port_check)
451
+ port_check.set_defaults(func=_port_check)
452
+
453
+ process = subparsers.add_parser("process", help="Process lifecycle helpers")
454
+ process_sub = process.add_subparsers(dest="action", required=True)
455
+ start = process_sub.add_parser("start", help="Start a process and optionally write a PID file")
456
+ start.add_argument("--pid-file")
457
+ start.add_argument("--cwd")
458
+ start.add_argument("--stdout")
459
+ start.add_argument("--stderr")
460
+ start.add_argument("command", nargs=argparse.REMAINDER)
461
+ _add_json(start)
462
+ start.set_defaults(func=_process_start)
463
+ status_pid = process_sub.add_parser("status-pid", help="Check whether a PID is running")
464
+ status_pid.add_argument("pid", type=int)
465
+ _add_json(status_pid)
466
+ status_pid.set_defaults(func=_process_status_pid)
467
+ cleanup_pid = process_sub.add_parser("cleanup-pid", help="Best-effort cleanup by PID")
468
+ cleanup_pid.add_argument("pid", type=int)
469
+ cleanup_pid.add_argument("--grace-seconds", type=float, default=2.0)
470
+ _add_json(cleanup_pid)
471
+ cleanup_pid.set_defaults(func=_process_cleanup_pid)
472
+ cleanup_port = process_sub.add_parser("cleanup-port", help="Best-effort cleanup by port when supported")
473
+ cleanup_port.add_argument("port", type=int)
474
+ cleanup_port.add_argument("--host", default="127.0.0.1")
475
+ cleanup_port.add_argument("--timeout", type=float, default=0.5)
476
+ _add_json(cleanup_port)
477
+ cleanup_port.set_defaults(func=_process_cleanup_port)
478
+
479
+ web = subparsers.add_parser("web", help="Browser/server readiness helpers")
480
+ web_sub = web.add_subparsers(dest="action", required=True)
481
+ wait_ready = web_sub.add_parser("wait-ready", help="Wait for an HTTP endpoint to be ready")
482
+ wait_ready.add_argument("--url", required=True)
483
+ wait_ready.add_argument("--timeout", type=float, default=30.0)
484
+ wait_ready.add_argument("--interval", type=float, default=2.0)
485
+ wait_ready.add_argument("--request-timeout", type=float, default=2.0)
486
+ wait_ready.add_argument("--status", action="append", type=int, default=[200, 302])
487
+ _add_json(wait_ready)
488
+ wait_ready.set_defaults(func=_web_wait_ready)
489
+
490
+ return parser
491
+
492
+
493
+ def run_helper_command(argv: list[str] | tuple[str, ...]) -> HelperResult:
494
+ """Run a helper command argv and return the structured result."""
495
+ parser = build_parser()
496
+ args = parser.parse_args(list(argv))
497
+ return args.func(args)
498
+
499
+
500
+ def main(argv: list[str] | None = None) -> int:
501
+ parser = build_parser()
502
+ args = parser.parse_args(argv)
503
+ result = args.func(args)
504
+ return _emit(result, json_output=bool(getattr(args, "json", False)))
505
+
506
+
507
+ if __name__ == "__main__":
508
+ raise SystemExit(main())
@@ -27,7 +27,7 @@ import os
27
27
  import re
28
28
  import sys
29
29
 
30
- from utils import enrich_global_context, load_json_file, resolve_prompt_platform, setup_logging
30
+ from utils import enrich_global_context, helper_replacements, load_json_file, resolve_prompt_platform, setup_logging
31
31
  from continuation import add_continuation_args, append_continuation_handoff, is_continuation
32
32
 
33
33
 
@@ -1941,6 +1941,8 @@ def build_replacements(args, feature, features, global_context, script_dir):
1941
1941
  ),
1942
1942
  }
1943
1943
 
1944
+ replacements.update(helper_replacements(script_dir))
1945
+
1944
1946
  return replacements, effective_resume, browser_enabled, browser_tool
1945
1947
 
1946
1948
 
@@ -19,7 +19,7 @@ import os
19
19
  import re
20
20
  import sys
21
21
 
22
- from utils import enrich_global_context, load_json_file, read_checkpoint_system_section, resolve_prompt_platform, setup_logging
22
+ from utils import enrich_global_context, helper_replacements, load_json_file, read_checkpoint_system_section, resolve_prompt_platform, setup_logging
23
23
  from continuation import add_continuation_args, append_continuation_handoff, is_continuation
24
24
 
25
25
 
@@ -362,6 +362,8 @@ def build_replacements(args, bug, global_context, script_dir):
362
362
  "{{BROWSER_VERIFY_STEPS}}": browser_verify_steps,
363
363
  }
364
364
 
365
+ replacements.update(helper_replacements(script_dir))
366
+
365
367
  return replacements
366
368
 
367
369
 
@@ -19,7 +19,7 @@ import os
19
19
  import re
20
20
  import sys
21
21
 
22
- from utils import enrich_global_context, load_json_file, read_checkpoint_system_section, resolve_prompt_platform, setup_logging
22
+ from utils import enrich_global_context, helper_replacements, load_json_file, read_checkpoint_system_section, resolve_prompt_platform, setup_logging
23
23
  from continuation import add_continuation_args, append_continuation_handoff, is_continuation
24
24
 
25
25
 
@@ -544,6 +544,8 @@ def build_replacements(args, refactor, refactors, global_context, script_dir):
544
544
  "{{BROWSER_VERIFY_STEPS}}": browser_verify_steps,
545
545
  }
546
546
 
547
+ replacements.update(helper_replacements(script_dir))
548
+
547
549
  return replacements
548
550
 
549
551
 
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env python3
2
+ """Thin entrypoint for the PrizmKit prompt runtime helper CLI."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import sys
7
+ from pathlib import Path
8
+
9
+
10
+ def _bootstrap_import_path() -> None:
11
+ """Ensure direct script execution can import the runtime package."""
12
+ pipeline_root = Path(__file__).resolve().parents[1]
13
+ pipeline_root_text = str(pipeline_root)
14
+ if pipeline_root_text not in sys.path:
15
+ sys.path.insert(0, pipeline_root_text)
16
+
17
+
18
+ def main(argv: list[str] | None = None) -> int:
19
+ """Run the runtime helper CLI and return its process exit code."""
20
+ _bootstrap_import_path()
21
+ from prizmkit_runtime.runtime_helper import main as helper_main
22
+
23
+ return helper_main(argv)
24
+
25
+
26
+ if __name__ == "__main__":
27
+ raise SystemExit(main())