robotframework-harness 1.0.0__py3-none-any.whl

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 (82) hide show
  1. rf_harness/__init__.py +3 -0
  2. rf_harness/_assets/.claude-plugin/plugin.json +10 -0
  3. rf_harness/_assets/bin/rfh-launch +60 -0
  4. rf_harness/_assets/bin/rfh-launch.cmd +19 -0
  5. rf_harness/_assets/bin/rfh_launch.py +719 -0
  6. rf_harness/_assets/hooks/hooks.json +84 -0
  7. rf_harness/_assets/skills/rf-harness/SKILL.md +78 -0
  8. rf_harness/_assets/skills/rf-harness/references/browser.md +157 -0
  9. rf_harness/_assets/skills/rf-harness/references/debug.md +39 -0
  10. rf_harness/_assets/skills/rf-harness/references/explore.md +52 -0
  11. rf_harness/_assets/skills/rf-harness/references/requests.md +37 -0
  12. rf_harness/_assets/skills/rf-harness/references/results.md +34 -0
  13. rf_harness/_assets/skills/rf-harness/references/selenium.md +40 -0
  14. rf_harness/_assets/skills/rf-harness/references/waits.md +28 -0
  15. rf_harness/_assets/variants/codex/hooks.json +72 -0
  16. rf_harness/_assets/variants/copilot/hooks.json +53 -0
  17. rf_harness/_assets/variants/opencode/plugins/rf-harness.js +130 -0
  18. rf_harness/config.py +255 -0
  19. rf_harness/installer/__init__.py +1 -0
  20. rf_harness/installer/_assets.py +73 -0
  21. rf_harness/installer/adapters/__init__.py +37 -0
  22. rf_harness/installer/adapters/_base.py +189 -0
  23. rf_harness/installer/adapters/claude_code.py +88 -0
  24. rf_harness/installer/adapters/codex.py +108 -0
  25. rf_harness/installer/adapters/copilot_cli.py +89 -0
  26. rf_harness/installer/adapters/opencode.py +62 -0
  27. rf_harness/installer/adapters/vscode.py +56 -0
  28. rf_harness/installer/cli.py +490 -0
  29. rf_harness/installer/codex_trust.py +190 -0
  30. rf_harness/installer/coexist.py +211 -0
  31. rf_harness/installer/demo_site/index.html +28 -0
  32. rf_harness/installer/doctor.py +343 -0
  33. rf_harness/installer/envfind.py +354 -0
  34. rf_harness/installer/executor.py +288 -0
  35. rf_harness/installer/homes.py +102 -0
  36. rf_harness/installer/manifest.py +204 -0
  37. rf_harness/installer/prompt.py +58 -0
  38. rf_harness/installer/transforms.py +483 -0
  39. rf_harness/runtime/__init__.py +1 -0
  40. rf_harness/runtime/analyzer.py +132 -0
  41. rf_harness/runtime/data/failure_rules.json +77 -0
  42. rf_harness/runtime/data/kwnames_cache.json +1 -0
  43. rf_harness/runtime/data/repl_format_rules.json +455 -0
  44. rf_harness/runtime/data/robocop-agent.toml +58 -0
  45. rf_harness/runtime/desktop/__init__.py +87 -0
  46. rf_harness/runtime/desktop/_env.py +134 -0
  47. rf_harness/runtime/desktop/decision.py +616 -0
  48. rf_harness/runtime/desktop/focus.py +1094 -0
  49. rf_harness/runtime/desktop/guidance.py +91 -0
  50. rf_harness/runtime/desktop/launch_env.py +562 -0
  51. rf_harness/runtime/desktop/safety.py +509 -0
  52. rf_harness/runtime/desktop/signals.py +321 -0
  53. rf_harness/runtime/docs.py +190 -0
  54. rf_harness/runtime/docsgen.py +174 -0
  55. rf_harness/runtime/engine.py +1000 -0
  56. rf_harness/runtime/execd.py +1131 -0
  57. rf_harness/runtime/guards.py +294 -0
  58. rf_harness/runtime/hints.py +1523 -0
  59. rf_harness/runtime/hook.py +1897 -0
  60. rf_harness/runtime/hookcore.py +832 -0
  61. rf_harness/runtime/kwdoc.py +285 -0
  62. rf_harness/runtime/kwdocs.py +286 -0
  63. rf_harness/runtime/leanfmt.py +451 -0
  64. rf_harness/runtime/lintd.py +423 -0
  65. rf_harness/runtime/livetmo.py +385 -0
  66. rf_harness/runtime/pagestate.py +489 -0
  67. rf_harness/runtime/portable.py +191 -0
  68. rf_harness/runtime/procs.py +106 -0
  69. rf_harness/runtime/profiles.py +144 -0
  70. rf_harness/runtime/proto.py +120 -0
  71. rf_harness/runtime/pwlog.py +89 -0
  72. rf_harness/runtime/replfmt.py +556 -0
  73. rf_harness/runtime/rfh_repl_listener.py +244 -0
  74. rf_harness/runtime/richfmt.py +301 -0
  75. rf_harness/runtime/robotcode_adapter.py +157 -0
  76. rf_harness/runtime/rthooks.py +623 -0
  77. rf_harness/runtime/smoke.py +113 -0
  78. robotframework_harness-1.0.0.dist-info/METADATA +85 -0
  79. robotframework_harness-1.0.0.dist-info/RECORD +82 -0
  80. robotframework_harness-1.0.0.dist-info/WHEEL +4 -0
  81. robotframework_harness-1.0.0.dist-info/entry_points.txt +3 -0
  82. robotframework_harness-1.0.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,719 @@
1
+ """rf-harness hook launcher: find the project's Python and hand the hook event to ``rf_harness.runtime.hook``.
2
+
3
+ Every agent's hook configuration runs::
4
+
5
+ python3 -S <plugin-or-install-dir>/bin/rfh-launch <Event> --agent <agent|auto> [--set RFH_NAME=value]...
6
+
7
+ with the agent's JSON payload on stdin. This module is stdlib only, runs on any Python 3.6+, and never fails:
8
+ a missing or incompatible runtime prints ONE setup hint on SessionStart and nothing otherwise, and every internal
9
+ error exits 0 (a crashing pre-tool hook denies the tool in Copilot CLI).
10
+
11
+ Interpreter order (design D3):
12
+ 1. ``RFH_PYTHON`` (authoritative: no fallback when it lacks the runtime),
13
+ 2. ``<project>/.rf/harness.json`` (written by ``rf-harness install``),
14
+ 3. ``VIRTUAL_ENV`` / ``CONDA_PREFIX``,
15
+ 4. ``<project>/.venv``,
16
+ 5. ``UV_PROJECT_ENVIRONMENT`` or the project's poetry environment (computed, no subprocess),
17
+ 6. ``python3`` on PATH.
18
+ The first interpreter whose site-packages hold ``robotframework_harness-<X.Y>.*.dist-info`` with the same major.minor
19
+ as ``LAUNCHER_VERSION`` and an ``rf_harness/runtime/hook.py`` wins. Nothing heavy is imported: venv site-packages are
20
+ scanned on disk; other interpreters report their ``sys.path`` once (cached per interpreter in the user cache dir).
21
+
22
+ Agent ``auto`` (the shared plugin hooks.json, read by Claude Code, Copilot CLI, VS Code and Codex) is resolved from
23
+ the environment, the parent processes and the payload. Copilot's SessionEnd is dropped (it fires after every run).
24
+ Identical events delivered twice within a few seconds (overlapping hook configurations) run once.
25
+ """
26
+
27
+ import os
28
+ import sys
29
+ import time
30
+
31
+ LAUNCHER_VERSION = "1.0.0"
32
+ SETUP_COMMAND = "uvx robotframework-harness install --runtime-only"
33
+ RUNTIME_MODULE = "rf_harness.runtime.hook"
34
+ DIST_PREFIX = "robotframework_harness-"
35
+ AGENTS = ("claude", "codex", "copilot", "vscode", "opencode")
36
+ AGENT_ALIASES = {"claude-code": "claude", "copilot-cli": "copilot", "vs-code": "vscode"}
37
+ CANONICAL_EVENTS = {
38
+ "sessionstart": "SessionStart",
39
+ "userpromptsubmit": "UserPromptSubmit",
40
+ "userpromptsubmitted": "UserPromptSubmit",
41
+ "pretooluse": "PreToolUse",
42
+ "posttooluse": "PostToolUse",
43
+ "posttoolusefailure": "PostToolUseFailure",
44
+ "stop": "Stop",
45
+ "agentstop": "Stop",
46
+ "sessionend": "SessionEnd",
47
+ }
48
+ VSCODE_TOOLS = frozenset((
49
+ "create_file", "replace_string_in_file", "multi_replace_string_in_file", "insert_edit_into_file", "edit_file",
50
+ "run_in_terminal", "read_file", "get_terminal_output", "manage_todo_list",
51
+ ))
52
+ ANCESTOR_AGENTS = (("codex", "codex"), ("claude", "claude"), ("copilot", "copilot"), ("code", "vscode"),
53
+ ("code-insiders", "vscode"), ("opencode", "opencode"))
54
+ PIPE_SAFE_BYTES = 16000 # smallest default pipe buffer (macOS); larger payloads go through a temp file
55
+ DEDUPE_SECONDS = 5.0
56
+ TRACE_ENV = "RF_HARNESS_LAUNCH_TRACE"
57
+ DEDUPE_ENV = "RF_HARNESS_DEDUPE_S"
58
+ CACHE_ENV = "RF_HARNESS_CACHE_DIR"
59
+ IS_WINDOWS = os.name == "nt"
60
+
61
+
62
+ # ------------------------------------------------------------------------------------------------ JSON
63
+ # `import json` pulls in re/enum/functools/collections (~15 ms under -S, most of the launcher's own time); the C
64
+ # accelerator `_json` scans and encodes without them. Any problem falls back to the json module.
65
+ class _ScanContext:
66
+ strict = True
67
+ object_hook = None
68
+ object_pairs_hook = None
69
+ parse_float = float
70
+ parse_int = int
71
+ memo = {}
72
+
73
+ @staticmethod
74
+ def parse_constant(name):
75
+ return {"NaN": float("nan"), "Infinity": float("inf"), "-Infinity": float("-inf")}[name]
76
+
77
+
78
+ _WS = " \t\n\r"
79
+ try:
80
+ import _json
81
+
82
+ _SCAN = _json.make_scanner(_ScanContext())
83
+ _ENC = _json.encode_basestring_ascii
84
+ except Exception: # pragma: no cover - e.g. PyPy
85
+ _SCAN = _ENC = None
86
+
87
+
88
+ def loads(text):
89
+ if _SCAN is not None:
90
+ start = len(text) - len(text.lstrip(_WS))
91
+ try:
92
+ value, end = _SCAN(text, start)
93
+ except Exception:
94
+ value, end = None, -1
95
+ if end >= 0 and not text[end:].strip(_WS):
96
+ return value
97
+ import json
98
+
99
+ return json.loads(text)
100
+
101
+
102
+ def dumps(value):
103
+ if _ENC is None: # pragma: no cover
104
+ import json
105
+
106
+ return json.dumps(value)
107
+ if value is None:
108
+ return "null"
109
+ if value is True:
110
+ return "true"
111
+ if value is False:
112
+ return "false"
113
+ if isinstance(value, str):
114
+ return _ENC(value)
115
+ if isinstance(value, (int, float)):
116
+ return repr(value) if value == value and value not in (float("inf"), float("-inf")) else "null"
117
+ if isinstance(value, dict):
118
+ return "{" + ", ".join(_ENC(str(k)) + ": " + dumps(v) for k, v in value.items()) + "}"
119
+ if isinstance(value, (list, tuple)):
120
+ return "[" + ", ".join(dumps(v) for v in value) + "]"
121
+ return _ENC(str(value))
122
+
123
+
124
+ # ------------------------------------------------------------------------------------------------ arguments / input
125
+ def parse_args(argv):
126
+ """Return (event, agent, sets, rest). ``--set RFH_X=v`` is consumed here; unknown args go to the runtime."""
127
+ event, agent, sets, rest = "", "auto", {}, []
128
+ i = 0
129
+ while i < len(argv):
130
+ arg = argv[i]
131
+ if arg == "--agent" and i + 1 < len(argv):
132
+ agent = argv[i + 1]
133
+ i += 2
134
+ continue
135
+ if arg.startswith("--agent="):
136
+ agent = arg.split("=", 1)[1]
137
+ i += 1
138
+ continue
139
+ if arg == "--set" and i + 1 < len(argv):
140
+ key, _, value = argv[i + 1].partition("=")
141
+ if key.startswith("RFH_"):
142
+ sets[key] = value
143
+ i += 2
144
+ continue
145
+ if not event and not arg.startswith("-"):
146
+ event = arg
147
+ else:
148
+ rest.append(arg)
149
+ i += 1
150
+ agent = (agent or "auto").strip().lower()
151
+ return event, AGENT_ALIASES.get(agent, agent), sets, rest
152
+
153
+
154
+ def canonical_event(event):
155
+ return CANONICAL_EVENTS.get(event.replace("_", "").replace("-", "").lower(), event)
156
+
157
+
158
+ def read_stdin():
159
+ try:
160
+ stream = sys.stdin
161
+ if stream is None or stream.isatty():
162
+ return b""
163
+ return stream.buffer.read()
164
+ except Exception:
165
+ return b""
166
+
167
+
168
+ def parse_payload(data):
169
+ if not data or not data.strip():
170
+ return {}
171
+ try:
172
+ payload = loads(data.decode("utf-8", "replace"))
173
+ except ValueError:
174
+ return {}
175
+ return payload if isinstance(payload, dict) else {}
176
+
177
+
178
+ # ------------------------------------------------------------------------------------------------ agent detection
179
+ def ancestor_agent(max_depth=8):
180
+ """Agent named by the nearest parent process (Linux /proc only; cheap file reads)."""
181
+ if not os.path.isdir("/proc/self"):
182
+ return None
183
+ pid = os.getppid()
184
+ for _ in range(max_depth):
185
+ if pid <= 1:
186
+ return None
187
+ try:
188
+ with open(f"/proc/{pid}/cmdline", "rb") as fh:
189
+ parts = [p for p in fh.read().split(b"\0") if p][:2]
190
+ with open(f"/proc/{pid}/stat", "rb") as fh:
191
+ stat = fh.read()
192
+ except OSError:
193
+ return None
194
+ names = [os.path.basename(p.decode("utf-8", "replace")).lower() for p in parts]
195
+ for exe, agent in ANCESTOR_AGENTS:
196
+ if any(n == exe or n == exe + ".exe" for n in names):
197
+ return agent
198
+ try:
199
+ pid = int(stat[stat.rindex(b")") + 2:].split()[1])
200
+ except (ValueError, IndexError):
201
+ return None
202
+ return None
203
+
204
+
205
+ def detect_agent(event, payload, environ, ancestry=None):
206
+ """Concrete agent for ``--agent auto``: environment, then parent processes, then the payload shape."""
207
+ if environ.get("COPILOT_CLI"):
208
+ return "copilot"
209
+ if environ.get("CLAUDECODE"):
210
+ return "claude"
211
+ if "turn_id" in payload:
212
+ return "codex"
213
+ tool = str(payload.get("tool_name") or "")
214
+ tool_input = payload.get("tool_input") if isinstance(payload.get("tool_input"), dict) else {}
215
+ if tool in VSCODE_TOOLS or (tool == "apply_patch" and "input" in tool_input) or "filePath" in tool_input:
216
+ return "vscode"
217
+ found = ancestry() if ancestry is not None else ancestor_agent()
218
+ if found:
219
+ return found
220
+ if "model" in payload and "transcript_path" in payload:
221
+ return "codex"
222
+ if any(k.startswith("VSCODE_") for k in environ):
223
+ return "vscode"
224
+ if event[:1].islower():
225
+ return "copilot"
226
+ return "claude"
227
+
228
+
229
+ # ------------------------------------------------------------------------------------------------ project root
230
+ def _has_robot_files(directory, limit=4000):
231
+ try:
232
+ with os.scandir(directory) as it:
233
+ for n, entry in enumerate(it):
234
+ if n >= limit:
235
+ return False
236
+ if entry.name.endswith(".robot"):
237
+ return True
238
+ except OSError:
239
+ return False
240
+ return False
241
+
242
+
243
+ def project_root(start):
244
+ """Nearest ancestor with .rf/, .git or pyproject.toml; else the nearest with *.robot files; else ``start``."""
245
+ start = os.path.abspath(start)
246
+ home = os.path.abspath(os.path.expanduser("~"))
247
+ directory, robot_dir = start, None
248
+ for _ in range(64):
249
+ if (os.path.isdir(os.path.join(directory, ".rf")) or os.path.exists(os.path.join(directory, ".git"))
250
+ or os.path.isfile(os.path.join(directory, "pyproject.toml"))):
251
+ return directory
252
+ if robot_dir is None and directory != home and _has_robot_files(directory):
253
+ robot_dir = directory
254
+ parent = os.path.dirname(directory)
255
+ if parent == directory:
256
+ break
257
+ directory = parent
258
+ return robot_dir or start
259
+
260
+
261
+ def start_dir(payload):
262
+ cwd = payload.get("cwd") if isinstance(payload.get("cwd"), str) else ""
263
+ if cwd and os.path.isdir(cwd):
264
+ return cwd
265
+ return os.getcwd()
266
+
267
+
268
+ # ------------------------------------------------------------------------------------------------ interpreters
269
+ def read_json(path):
270
+ try:
271
+ with open(path, encoding="utf-8") as fh:
272
+ data = loads(fh.read())
273
+ return data if isinstance(data, dict) else {}
274
+ except (OSError, ValueError):
275
+ return {}
276
+
277
+
278
+ def venv_python(prefix):
279
+ if not prefix:
280
+ return None
281
+ names = (("Scripts", "python.exe"), ("python.exe",)) if IS_WINDOWS else (("bin", "python"), ("bin", "python3"))
282
+ for parts in names:
283
+ path = os.path.join(prefix, *parts)
284
+ if os.path.isfile(path):
285
+ return path
286
+ return None
287
+
288
+
289
+ def _project_name(pyproject_text):
290
+ import re
291
+
292
+ for section in ("tool.poetry", "project"):
293
+ match = re.search(r"^\[" + re.escape(section) + r"\]\s*$(.*?)(?=^\[|\Z)", pyproject_text, re.M | re.S)
294
+ if match:
295
+ name = re.search(r"""^name\s*=\s*["']([^"']+)["']""", match.group(1), re.M)
296
+ if name:
297
+ return name.group(1)
298
+ return None
299
+
300
+
301
+ def poetry_virtualenvs_dir(environ):
302
+ if environ.get("POETRY_VIRTUALENVS_PATH"):
303
+ return environ["POETRY_VIRTUALENVS_PATH"]
304
+ if environ.get("POETRY_CACHE_DIR"):
305
+ return os.path.join(environ["POETRY_CACHE_DIR"], "virtualenvs")
306
+ home = os.path.expanduser("~")
307
+ if IS_WINDOWS:
308
+ base = environ.get("LOCALAPPDATA") or os.path.join(home, "AppData", "Local")
309
+ return os.path.join(base, "pypoetry", "Cache", "virtualenvs")
310
+ if sys.platform == "darwin":
311
+ return os.path.join(home, "Library", "Caches", "pypoetry", "virtualenvs")
312
+ return os.path.join(environ.get("XDG_CACHE_HOME") or os.path.join(home, ".cache"), "pypoetry", "virtualenvs")
313
+
314
+
315
+ def poetry_env_name(name, root):
316
+ """Poetry's ``EnvManager.generate_env_name`` (name + 8 chars of the hashed project path)."""
317
+ import base64
318
+ import hashlib
319
+ import re
320
+
321
+ sanitized = re.sub(r'[ $`!*@"\\\r\n\t]', "_", name.lower())[:42]
322
+ digest = hashlib.sha256(os.path.normcase(os.path.realpath(root)).encode("utf-8")).digest()
323
+ return sanitized + "-" + base64.urlsafe_b64encode(digest).decode()[:8]
324
+
325
+
326
+ def poetry_python(root, environ):
327
+ pyproject = os.path.join(root, "pyproject.toml")
328
+ try:
329
+ with open(pyproject, encoding="utf-8") as fh:
330
+ text = fh.read()
331
+ except OSError:
332
+ return None
333
+ if "[tool.poetry" not in text and not os.path.isfile(os.path.join(root, "poetry.lock")):
334
+ return None
335
+ name = _project_name(text)
336
+ base = poetry_virtualenvs_dir(environ)
337
+ if not name or not os.path.isdir(base):
338
+ return None
339
+ prefix = poetry_env_name(name, root) + "-py"
340
+ try:
341
+ matches = sorted(e for e in os.listdir(base) if e.startswith(prefix))
342
+ except OSError:
343
+ return None
344
+ for entry in reversed(matches):
345
+ python = venv_python(os.path.join(base, entry))
346
+ if python:
347
+ return python
348
+ return None
349
+
350
+
351
+ def path_python(environ):
352
+ names = ("python.exe", "python3.exe") if IS_WINDOWS else ("python3",)
353
+ for directory in (environ.get("PATH") or "").split(os.pathsep):
354
+ for name in names:
355
+ path = os.path.join(directory, name)
356
+ if os.path.isfile(path) and os.access(path, os.X_OK):
357
+ return path
358
+ return None
359
+
360
+
361
+ def candidates(root, environ):
362
+ """(source, python) pairs in the D3 order; RFH_PYTHON alone when set."""
363
+ explicit = environ.get("RFH_PYTHON")
364
+ if explicit:
365
+ return [("RFH_PYTHON", explicit)]
366
+ found = []
367
+ harness = read_json(os.path.join(root, ".rf", "harness.json"))
368
+ if isinstance(harness.get("python"), str):
369
+ found.append(("harness.json", harness["python"]))
370
+ for var in ("VIRTUAL_ENV", "CONDA_PREFIX"):
371
+ python = venv_python(environ.get(var))
372
+ if python:
373
+ found.append((var, python))
374
+ python = venv_python(os.path.join(root, ".venv"))
375
+ if python:
376
+ found.append((".venv", python))
377
+ uv_env = environ.get("UV_PROJECT_ENVIRONMENT")
378
+ if uv_env:
379
+ python = venv_python(uv_env if os.path.isabs(uv_env) else os.path.join(root, uv_env))
380
+ if python:
381
+ found.append(("UV_PROJECT_ENVIRONMENT", python))
382
+ python = poetry_python(root, environ)
383
+ if python:
384
+ found.append(("poetry", python))
385
+ python = path_python(environ)
386
+ if python:
387
+ found.append(("PATH", python))
388
+ seen, unique = set(), []
389
+ for source, python in found:
390
+ key = os.path.normcase(os.path.abspath(python))
391
+ if key not in seen:
392
+ seen.add(key)
393
+ unique.append((source, python))
394
+ return unique
395
+
396
+
397
+ # ------------------------------------------------------------------------------------------------ runtime check
398
+ def _expand_pth(site_dir, dirs):
399
+ try:
400
+ names = sorted(n for n in os.listdir(site_dir) if n.endswith(".pth"))
401
+ except OSError:
402
+ return
403
+ for name in names:
404
+ try:
405
+ with open(os.path.join(site_dir, name), encoding="utf-8", errors="replace") as fh:
406
+ for line in fh:
407
+ line = line.strip()
408
+ if not line or line.startswith(("#", "import ", "import\t")):
409
+ continue
410
+ path = line if os.path.isabs(line) else os.path.join(site_dir, line)
411
+ if os.path.isdir(path) and path not in dirs:
412
+ dirs.append(path)
413
+ except OSError:
414
+ continue
415
+
416
+
417
+ def static_site_dirs(python):
418
+ """site-packages of a venv / conda interpreter, found on disk (plus path entries of its .pth files)."""
419
+ prefix = os.path.dirname(os.path.dirname(os.path.abspath(python)))
420
+ if IS_WINDOWS and not os.path.isfile(os.path.join(prefix, "pyvenv.cfg")):
421
+ prefix = os.path.dirname(os.path.abspath(python))
422
+ if not (os.path.isfile(os.path.join(prefix, "pyvenv.cfg")) or os.path.isdir(os.path.join(prefix, "conda-meta"))):
423
+ return None
424
+ dirs = []
425
+ if IS_WINDOWS:
426
+ candidates_ = [os.path.join(prefix, "Lib", "site-packages")]
427
+ else:
428
+ lib = os.path.join(prefix, "lib")
429
+ try:
430
+ candidates_ = [os.path.join(lib, n, "site-packages") for n in sorted(os.listdir(lib)) if n.startswith("python")]
431
+ except OSError:
432
+ candidates_ = []
433
+ for site_dir in candidates_:
434
+ if os.path.isdir(site_dir):
435
+ dirs.append(site_dir)
436
+ for site_dir in list(dirs):
437
+ _expand_pth(site_dir, dirs)
438
+ return dirs
439
+
440
+
441
+ def cache_dir(environ):
442
+ if environ.get(CACHE_ENV):
443
+ return environ[CACHE_ENV]
444
+ home = os.path.expanduser("~")
445
+ if IS_WINDOWS:
446
+ return os.path.join(environ.get("LOCALAPPDATA") or os.path.join(home, "AppData", "Local"), "rf-harness")
447
+ return os.path.join(environ.get("XDG_CACHE_HOME") or os.path.join(home, ".cache"), "rf-harness")
448
+
449
+
450
+ def queried_sys_path(python, environ):
451
+ """``sys.path`` reported by the interpreter itself, cached per interpreter path + mtime."""
452
+ try:
453
+ stamp = f"{os.path.abspath(python)}|{os.stat(python).st_mtime}"
454
+ except OSError:
455
+ return []
456
+ cache_file = os.path.join(cache_dir(environ), "launch-paths.json")
457
+ cache = read_json(cache_file)
458
+ if isinstance(cache.get(stamp), list):
459
+ return cache[stamp]
460
+ import subprocess
461
+
462
+ env = dict(environ)
463
+ env.pop("PYTHONSTARTUP", None)
464
+ try:
465
+ proc = subprocess.Popen([python, "-c", "import sys,json;print(json.dumps([p for p in sys.path if p]))"],
466
+ stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env)
467
+ out, _ = proc.communicate(timeout=5)
468
+ paths = loads(out.decode("utf-8", "replace") or "[]")
469
+ except Exception:
470
+ return []
471
+ if not isinstance(paths, list):
472
+ return []
473
+ try:
474
+ os.makedirs(os.path.dirname(cache_file), exist_ok=True)
475
+ cache = {k: v for k, v in cache.items() if isinstance(v, list)}
476
+ cache[stamp] = paths
477
+ tmp = f"{cache_file}.{os.getpid()}.tmp"
478
+ with open(tmp, "w", encoding="utf-8") as fh:
479
+ fh.write(dumps(dict(list(cache.items())[-32:])))
480
+ os.replace(tmp, cache_file)
481
+ except OSError:
482
+ pass
483
+ return paths
484
+
485
+
486
+ def runtime_in(dirs):
487
+ """(version or None, has_runtime_package) for a list of import directories."""
488
+ version, has_runtime = None, False
489
+ for directory in dirs:
490
+ if not has_runtime and os.path.isfile(os.path.join(directory, "rf_harness", "runtime", "hook.py")):
491
+ has_runtime = True
492
+ if version is None:
493
+ try:
494
+ names = os.listdir(directory)
495
+ except OSError:
496
+ continue
497
+ for name in names:
498
+ if name.startswith(DIST_PREFIX) and name.endswith(".dist-info"):
499
+ version = name[len(DIST_PREFIX):-len(".dist-info")] or _metadata_version(directory, name)
500
+ break
501
+ return version, has_runtime
502
+
503
+
504
+ def _metadata_version(directory, dist_info):
505
+ try:
506
+ with open(os.path.join(directory, dist_info, "METADATA"), encoding="utf-8", errors="replace") as fh:
507
+ for line in fh:
508
+ if line.startswith("Version:"):
509
+ return line.split(":", 1)[1].strip()
510
+ if not line.strip():
511
+ break
512
+ except OSError:
513
+ pass
514
+ return None
515
+
516
+
517
+ def compatible(version):
518
+ return bool(version) and version.split(".")[:2] == LAUNCHER_VERSION.split(".")[:2]
519
+
520
+
521
+ def inspect_python(python, root, environ, harness):
522
+ """Return (status, version) with status in ok | missing | incompatible | absent."""
523
+ if not python or not os.path.isfile(python):
524
+ return "absent", None
525
+ dirs = static_site_dirs(python)
526
+ version, has_runtime = runtime_in(dirs or [])
527
+ if not (version and has_runtime):
528
+ recorded = harness.get("site_packages") if harness.get("python") == python else None
529
+ dirs = recorded if isinstance(recorded, list) and recorded else queried_sys_path(python, environ)
530
+ version, has_runtime = runtime_in(dirs)
531
+ if not (version and has_runtime):
532
+ return "missing", version
533
+ return ("ok" if compatible(version) else "incompatible"), version
534
+
535
+
536
+ def resolve(root, environ):
537
+ """First compatible interpreter: (python, source, status, version). status is that of the best candidate."""
538
+ harness = read_json(os.path.join(root, ".rf", "harness.json"))
539
+ first = None
540
+ for source, python in candidates(root, environ):
541
+ status, version = inspect_python(python, root, environ, harness)
542
+ if status == "ok":
543
+ return python, source, status, version
544
+ if first is None or (first[2] != "incompatible" and status == "incompatible"):
545
+ first = (python, source, status, version)
546
+ return first or (None, None, "missing", None)
547
+
548
+
549
+ # ------------------------------------------------------------------------------------------------ output / hand-off
550
+ def setup_hint(agent, status, python, version):
551
+ where = f" ({python})" if python else ""
552
+ if status == "incompatible":
553
+ text = (f"[rfh] The Robot Framework harness runtime {version} in this project's Python environment{where} "
554
+ f"does not match the plugin {LAUNCHER_VERSION}; the harness hooks are inactive until you run: "
555
+ f"{SETUP_COMMAND}")
556
+ else:
557
+ text = (f"[rfh] The Robot Framework harness runtime is not installed in this project's Python environment"
558
+ f"{where}; the harness hooks are inactive until you run: {SETUP_COMMAND}")
559
+ if agent == "opencode":
560
+ return {"action": "context", "text": text}
561
+ if agent == "copilot":
562
+ return {"additionalContext": text}
563
+ return {"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": text}}
564
+
565
+
566
+ def trace(environ, record):
567
+ path = environ.get(TRACE_ENV)
568
+ if not path:
569
+ return
570
+ try:
571
+ with open(path, "a", encoding="utf-8") as fh:
572
+ fh.write(dumps(record) + "\n")
573
+ except OSError:
574
+ pass
575
+
576
+
577
+ def is_duplicate(event, agent, data, environ):
578
+ """True when the same event + payload was already launched within DEDUPE_SECONDS."""
579
+ try:
580
+ window = float(environ.get(DEDUPE_ENV, DEDUPE_SECONDS))
581
+ except ValueError:
582
+ window = DEDUPE_SECONDS
583
+ if window <= 0 or not data.strip():
584
+ return False
585
+ import zlib
586
+
587
+ blob = f"{event}\0{agent}\0".encode() + data
588
+ key = f"{zlib.crc32(blob):08x}{zlib.adler32(blob):08x}{len(blob):x}"
589
+ directory = os.path.join(cache_dir(environ), "dedupe")
590
+ path = os.path.join(directory, key)
591
+ now = time.time()
592
+ try:
593
+ os.makedirs(directory, exist_ok=True)
594
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
595
+ os.close(fd)
596
+ except FileExistsError:
597
+ try:
598
+ if now - os.stat(path).st_mtime < window:
599
+ return True
600
+ os.utime(path, None)
601
+ except OSError:
602
+ return False
603
+ return False
604
+ except OSError:
605
+ return False
606
+ if int(now * 1000) % 50 == 0: # occasional cleanup of old markers
607
+ try:
608
+ for name in os.listdir(directory):
609
+ stale = os.path.join(directory, name)
610
+ if now - os.stat(stale).st_mtime > 60:
611
+ os.unlink(stale)
612
+ except OSError:
613
+ pass
614
+ return False
615
+
616
+
617
+ def refeed_stdin(data):
618
+ """Make ``data`` the next process's stdin (fd 0) before exec."""
619
+ if len(data) <= PIPE_SAFE_BYTES:
620
+ read_fd, write_fd = os.pipe()
621
+ view = memoryview(data)
622
+ while view:
623
+ written = os.write(write_fd, view)
624
+ view = view[written:]
625
+ os.close(write_fd)
626
+ os.dup2(read_fd, 0)
627
+ os.close(read_fd)
628
+ return
629
+ import tempfile
630
+
631
+ tmp = tempfile.TemporaryFile()
632
+ tmp.write(data)
633
+ tmp.flush()
634
+ tmp.seek(0)
635
+ os.dup2(tmp.fileno(), 0)
636
+
637
+
638
+ def hand_off(python, event, agent, rest, data, env):
639
+ argv = [python, "-m", RUNTIME_MODULE, event, "--agent", agent] + list(rest)
640
+ if IS_WINDOWS: # no real exec on Windows: run, forward stdout and the exit code
641
+ import subprocess
642
+
643
+ try:
644
+ sys.stdout.flush()
645
+ except Exception:
646
+ pass
647
+ try:
648
+ code = subprocess.run(argv, input=data, env=env).returncode
649
+ except OSError:
650
+ return 0
651
+ return code if code in (0, 2) else 0
652
+ refeed_stdin(data)
653
+ os.execve(python, argv, env)
654
+ return 0 # pragma: no cover - execve does not return
655
+
656
+
657
+ def emit(obj):
658
+ try:
659
+ sys.stdout.write(dumps(obj) + "\n")
660
+ sys.stdout.flush()
661
+ except Exception:
662
+ pass
663
+
664
+
665
+ def run(argv, environ=None):
666
+ environ = dict(os.environ if environ is None else environ)
667
+ started = time.time()
668
+ event, agent, sets, rest = parse_args(argv)
669
+ data = read_stdin()
670
+ payload = parse_payload(data)
671
+ canon = canonical_event(event)
672
+ if agent not in AGENTS:
673
+ agent = detect_agent(event, payload, environ)
674
+ record = {"event": event, "agent": agent, "launcher": LAUNCHER_VERSION}
675
+ if agent == "copilot" and canon == "SessionEnd":
676
+ record.update(action="skip", reason="copilot fires SessionEnd after every run")
677
+ trace(environ, record)
678
+ return 0
679
+ root = project_root(start_dir(payload))
680
+ python, source, status, version = resolve(root, environ)
681
+ record.update(root=root, python=python, source=source, status=status, version=version)
682
+ if status != "ok":
683
+ record["action"] = "hint" if canon == "SessionStart" else "skip"
684
+ record["ms"] = round((time.time() - started) * 1000, 1)
685
+ trace(environ, record)
686
+ if canon == "SessionStart":
687
+ emit(setup_hint(agent, status, python, version))
688
+ return 0
689
+ if is_duplicate(canon, agent, data, environ):
690
+ record.update(action="skip", reason="duplicate delivery")
691
+ trace(environ, record)
692
+ return 0
693
+ env = dict(environ)
694
+ harness_env = read_json(os.path.join(root, ".rf", "harness.json")).get("env")
695
+ if isinstance(harness_env, dict):
696
+ for key, value in harness_env.items():
697
+ if isinstance(key, str) and key.startswith("RFH_") and key not in env:
698
+ env[key] = str(value)
699
+ env.update(sets)
700
+ record.update(action="exec", ms=round((time.time() - started) * 1000, 1))
701
+ trace(environ, record)
702
+ return hand_off(python, event, agent, rest, data, env)
703
+
704
+
705
+ def main(argv=None):
706
+ code = 0
707
+ try:
708
+ code = run(sys.argv[1:] if argv is None else argv)
709
+ except BaseException: # noqa: B036 - the launcher must never fail a hook
710
+ code = 0
711
+ try:
712
+ sys.stdout.flush()
713
+ except Exception:
714
+ pass
715
+ os._exit(code if code in (0, 2) else 0)
716
+
717
+
718
+ if __name__ == "__main__":
719
+ main()