git-repokit 0.9.10__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 (174) hide show
  1. git_repokit-0.9.10.dist-info/METADATA +422 -0
  2. git_repokit-0.9.10.dist-info/RECORD +174 -0
  3. git_repokit-0.9.10.dist-info/WHEEL +5 -0
  4. git_repokit-0.9.10.dist-info/entry_points.txt +2 -0
  5. git_repokit-0.9.10.dist-info/licenses/LICENSE +9 -0
  6. git_repokit-0.9.10.dist-info/top_level.txt +1 -0
  7. repokit/__init__.py +8 -0
  8. repokit/__main__.py +12 -0
  9. repokit/__version__.py +4 -0
  10. repokit/_vendor/__init__.py +0 -0
  11. repokit/_vendor/ailib/__init__.py +1 -0
  12. repokit/_vendor/ailib/analyzer.py +177 -0
  13. repokit/_vendor/ailib/backend.py +95 -0
  14. repokit/_vendor/ailib/cache.py +97 -0
  15. repokit/_vendor/ailib/parsers.py +52 -0
  16. repokit/_vendor/ailib/transports/__init__.py +16 -0
  17. repokit/_vendor/ailib/transports/cli.py +312 -0
  18. repokit/_vendor/ailib/transports/openai_compat.py +383 -0
  19. repokit/_vendor/ailib/transports/prompt_file.py +41 -0
  20. repokit/_vendor/ailib/types.py +224 -0
  21. repokit/auth_integration.py +244 -0
  22. repokit/badges.py +183 -0
  23. repokit/branch_utils.py +364 -0
  24. repokit/cli.py +2997 -0
  25. repokit/common_kit.py +202 -0
  26. repokit/config.py +686 -0
  27. repokit/defaults.py +220 -0
  28. repokit/directory_analyzer.py +1244 -0
  29. repokit/directory_profiles.py +423 -0
  30. repokit/git_config.py +370 -0
  31. repokit/github_create.py +142 -0
  32. repokit/github_setup.py +248 -0
  33. repokit/guardrails.py +707 -0
  34. repokit/history_cleaner.py +449 -0
  35. repokit/history_protection.py +434 -0
  36. repokit/hooks/pre-commit-guardrails +122 -0
  37. repokit/hooks/pre-commit-template +28 -0
  38. repokit/layers.py +510 -0
  39. repokit/licenses/0bsd.txt +14 -0
  40. repokit/licenses/LICENSES.md +27 -0
  41. repokit/licenses/__init__.py +142 -0
  42. repokit/licenses/agpl-3.0.txt +661 -0
  43. repokit/licenses/all-rights-reserved.txt +10 -0
  44. repokit/licenses/apache-2.0.txt +201 -0
  45. repokit/licenses/bsd-2-clause.txt +24 -0
  46. repokit/licenses/bsd-3-clause.txt +28 -0
  47. repokit/licenses/bsl-1.0.txt +23 -0
  48. repokit/licenses/cc-by-4.0.txt +395 -0
  49. repokit/licenses/cc-by-sa-4.0.txt +427 -0
  50. repokit/licenses/cc0-1.0.txt +121 -0
  51. repokit/licenses/epl-2.0.txt +277 -0
  52. repokit/licenses/gpl-2.0.txt +339 -0
  53. repokit/licenses/gpl-3.0.txt +674 -0
  54. repokit/licenses/isc.txt +15 -0
  55. repokit/licenses/lgpl-2.1.txt +504 -0
  56. repokit/licenses/lgpl-3.0.txt +165 -0
  57. repokit/licenses/mit.txt +21 -0
  58. repokit/licenses/mpl-2.0.txt +373 -0
  59. repokit/licenses/unlicense.txt +24 -0
  60. repokit/readme_blocks.py +112 -0
  61. repokit/remote_integration.py +627 -0
  62. repokit/repo_manager.py +2796 -0
  63. repokit/template_engine.py +391 -0
  64. repokit/templates/ai/claude/CLAUDE-nested.md.template +25 -0
  65. repokit/templates/ai/claude/CLAUDE.md.template +98 -0
  66. repokit/templates/ai/claude/instructions/step1_context_rebuilder.md +33 -0
  67. repokit/templates/ai/claude/instructions/step2_dev_workflow_process.md +67 -0
  68. repokit/templates/ai/claude/instructions/step3_context_bridge.md +52 -0
  69. repokit/templates/common/CHANGELOG.md.template +15 -0
  70. repokit/templates/common/CONTRIBUTING.md.template +31 -0
  71. repokit/templates/common/README.md.template +30 -0
  72. repokit/templates/common/gitattributes.template +17 -0
  73. repokit/templates/common/gitignore.template +141 -0
  74. repokit/templates/extras/FUNDING.yml.template +13 -0
  75. repokit/templates/extras/ROADMAP.md.template +17 -0
  76. repokit/templates/extras/dependabot.yml.template +22 -0
  77. repokit/templates/extras/one-offs-README.md.template +28 -0
  78. repokit/templates/extras/platform-support.md.template +11 -0
  79. repokit/templates/extras/pull_request_template.md.template +25 -0
  80. repokit/templates/extras/release-template.md.template +34 -0
  81. repokit/templates/extras/settings.json.template +11 -0
  82. repokit/templates/extras/stale.yml.template +14 -0
  83. repokit/templates/github/CODEOWNERS.template +11 -0
  84. repokit/templates/github/ISSUE_TEMPLATE/bug-report.md.template +29 -0
  85. repokit/templates/github/ISSUE_TEMPLATE/feature-request.md.template +19 -0
  86. repokit/templates/github/issues/notes.md.template +18 -0
  87. repokit/templates/github/issues/roadmap.md.template +15 -0
  88. repokit/templates/hooks/pre-commit.template +64 -0
  89. repokit/templates/layers/archetypes/any/claude-plugin/.claude-plugin/marketplace.json +20 -0
  90. repokit/templates/layers/archetypes/any/claude-plugin/plugin/.claude-plugin/plugin.json +15 -0
  91. repokit/templates/layers/archetypes/any/claude-plugin/plugin/README.md +28 -0
  92. repokit/templates/layers/archetypes/c-cpp/cmake-app/.github/workflows/ci-c-cpp.yml +24 -0
  93. repokit/templates/layers/archetypes/c-cpp/cmake-app/.repokit-common.toml.rkappend +4 -0
  94. repokit/templates/layers/archetypes/c-cpp/cmake-app/.vscode/launch.json +29 -0
  95. repokit/templates/layers/archetypes/c-cpp/cmake-app/.vscode/tasks.json +26 -0
  96. repokit/templates/layers/archetypes/c-cpp/cmake-app/CMakeLists.txt +47 -0
  97. repokit/templates/layers/archetypes/c-cpp/cmake-app/CMakePresets.json +23 -0
  98. repokit/templates/layers/archetypes/c-cpp/cmake-app/cmake/GenerateStamp.cmake +45 -0
  99. repokit/templates/layers/archetypes/c-cpp/cmake-app/src/main.cpp +27 -0
  100. repokit/templates/layers/archetypes/c-cpp/cmake-app/tests/test_version.cpp +20 -0
  101. repokit/templates/layers/archetypes/c-cpp/cmake-app/vcpkg.json +5 -0
  102. repokit/templates/layers/archetypes/c-cpp/minimal/.vscode/launch.json +27 -0
  103. repokit/templates/layers/archetypes/c-cpp/minimal/Makefile +10 -0
  104. repokit/templates/layers/archetypes/c-cpp/minimal/build.bat +7 -0
  105. repokit/templates/layers/archetypes/c-cpp/minimal/src/__RK_SNAKE__.c +26 -0
  106. repokit/templates/layers/archetypes/c-cpp/msbuild/.vscode/launch.json +17 -0
  107. repokit/templates/layers/archetypes/c-cpp/msbuild/.vscode/tasks.json +20 -0
  108. repokit/templates/layers/archetypes/c-cpp/msbuild/__RK_PROJECT__.sln +19 -0
  109. repokit/templates/layers/archetypes/c-cpp/msbuild/__RK_PROJECT__.vcxproj +41 -0
  110. repokit/templates/layers/archetypes/c-cpp/msbuild/src/main.cpp +26 -0
  111. repokit/templates/layers/archetypes/js-ts/claude-plugin/.repokit-common.toml +28 -0
  112. repokit/templates/layers/archetypes/js-ts/claude-plugin/.vscode/launch.json +34 -0
  113. repokit/templates/layers/archetypes/js-ts/claude-plugin/plugin/hooks/hooks.json +15 -0
  114. repokit/templates/layers/archetypes/js-ts/claude-plugin/plugin/hooks/scripts/session-start.mjs +23 -0
  115. repokit/templates/layers/archetypes/js-ts/claude-plugin/plugin/tests/manifest.test.js +30 -0
  116. repokit/templates/layers/archetypes/js-ts/node/.repokit-common.toml.rkappend +6 -0
  117. repokit/templates/layers/archetypes/js-ts/node/package.json +13 -0
  118. repokit/templates/layers/archetypes/js-ts/node-package/.github/workflows/release.yml +39 -0
  119. repokit/templates/layers/archetypes/js-ts/node-package/package.json +32 -0
  120. repokit/templates/layers/archetypes/js-ts/node-package/src/index.ts +13 -0
  121. repokit/templates/layers/archetypes/js-ts/node-package/tests/index.test.ts +11 -0
  122. repokit/templates/layers/archetypes/js-ts/script/.vscode/launch.json +34 -0
  123. repokit/templates/layers/archetypes/js-ts/script/__RK_PROJECT__.js +37 -0
  124. repokit/templates/layers/archetypes/js-ts/script/tests/version.test.js +26 -0
  125. repokit/templates/layers/archetypes/js-ts/ts/package.json +18 -0
  126. repokit/templates/layers/archetypes/js-ts/ts/tsconfig.json +15 -0
  127. repokit/templates/layers/archetypes/python/claude-plugin/.github/workflows/ci-python.yml +44 -0
  128. repokit/templates/layers/archetypes/python/claude-plugin/.repokit-common.toml +21 -0
  129. repokit/templates/layers/archetypes/python/claude-plugin/.vscode/launch.json +33 -0
  130. repokit/templates/layers/archetypes/python/claude-plugin/VERSION +1 -0
  131. repokit/templates/layers/archetypes/python/claude-plugin/plugin/hooks/hooks.json +15 -0
  132. repokit/templates/layers/archetypes/python/claude-plugin/plugin/hooks/scripts/run-hook.mjs +42 -0
  133. repokit/templates/layers/archetypes/python/claude-plugin/plugin/hooks/scripts/session_start.py +31 -0
  134. repokit/templates/layers/archetypes/python/claude-plugin/plugin/tests/test_manifest.py +42 -0
  135. repokit/templates/layers/archetypes/python/pypi/.github/workflows/release.yml +63 -0
  136. repokit/templates/layers/archetypes/python/pypi/.vscode/launch.json +43 -0
  137. repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/__init__.py +5 -0
  138. repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/__main__.py +6 -0
  139. repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/_version.py +96 -0
  140. repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/cli.py +18 -0
  141. repokit/templates/layers/archetypes/python/pypi/pyproject.toml +71 -0
  142. repokit/templates/layers/archetypes/python/pypi/tests/conftest.py +11 -0
  143. repokit/templates/layers/archetypes/python/pypi/tests/test_version.py +50 -0
  144. repokit/templates/layers/archetypes/python/script/.github/workflows/ci-python.yml +43 -0
  145. repokit/templates/layers/archetypes/python/script/.repokit-common.toml +14 -0
  146. repokit/templates/layers/archetypes/python/script/.vscode/launch.json +33 -0
  147. repokit/templates/layers/archetypes/python/script/VERSION +1 -0
  148. repokit/templates/layers/archetypes/python/script/__RK_PROJECT__.py +40 -0
  149. repokit/templates/layers/archetypes/python/script/tests/test_version.py +25 -0
  150. repokit/templates/layers/archetypes/python/setuppy/requirements.txt +8 -0
  151. repokit/templates/layers/archetypes/python/setuppy/setup.py +23 -0
  152. repokit/templates/layers/languages/c-cpp/.gitignore.rkappend +20 -0
  153. repokit/templates/layers/languages/c-cpp/.repokit-common.toml +7 -0
  154. repokit/templates/layers/languages/c-cpp/include/__RK_SNAKE__/version.h +20 -0
  155. repokit/templates/layers/languages/c-cpp/src/app.rc +25 -0
  156. repokit/templates/layers/languages/js-ts/.editorconfig +15 -0
  157. repokit/templates/layers/languages/js-ts/.github/workflows/ci-js-ts.yml +41 -0
  158. repokit/templates/layers/languages/js-ts/.gitignore.rkappend +13 -0
  159. repokit/templates/layers/languages/js-ts/.repokit-common.toml +19 -0
  160. repokit/templates/layers/languages/js-ts/.vscode/launch.json +24 -0
  161. repokit/templates/layers/languages/js-ts/VERSION +1 -0
  162. repokit/templates/layers/languages/python/.github/workflows/ci-python.yml +76 -0
  163. repokit/templates/layers/languages/python/.gitignore.rkappend +15 -0
  164. repokit/templates/layers/languages/python/.vscode/launch.json +33 -0
  165. repokit/templates/layers/manifest.toml +302 -0
  166. repokit/templates/vault/CLAUDE.md +29 -0
  167. repokit/templates/vault/claude/_maps/MOC-project.md +15 -0
  168. repokit/templates/vault/claude/_oracle/backlinks.md +3 -0
  169. repokit/templates/vault/claude/_oracle/concepts.md +12 -0
  170. repokit/templates/vault/claude/_oracle/manifest.md +10 -0
  171. repokit/templates/vault/claude/_todo/ACTIVE-WORK.md +18 -0
  172. repokit/templates/vault/claude/_todo/OPEN-LOOPS.md +14 -0
  173. repokit/utils.py +316 -0
  174. repokit/vault.py +248 -0
@@ -0,0 +1,312 @@
1
+ """The subprocess transport: an executable, argv from a template, the
2
+ prompt on stdin. claude, codex, pi, gemini-cli -- one transport, different
3
+ argv. Nothing about a vendor lives here; a preset says which executable,
4
+ which flags, where the answer comes out.
5
+
6
+ Two properties a long-lived caller cannot do without, and a one-shot CLI
7
+ never notices: the child's working directory and environment are HANDED TO
8
+ THE CHILD (a fresh scratch directory, a copied environment with the
9
+ preset's `env_unset` removed) and never set on this process. A caller that
10
+ runs two backends, or lives longer than one call, sees no shared state.
11
+
12
+ **A populated working directory** (2026-09-09): when the Request names a
13
+ `workdir`, the child runs THERE instead of in the throwaway scratch, may
14
+ read and write there under the preset's `tools` grant (substituted into
15
+ the `{tools}` placeholder only then -- claude's `--allowedTools` list,
16
+ codex's `-s` sandbox mode), and the directory is left exactly as the child
17
+ left it for the caller to read back. The scratch is still made for the
18
+ call's own files (a schema, a prompt) and still removed. Measured before
19
+ this was built: `claude -p --allowedTools Read,Grep` refuses an edit and
20
+ `Read,Grep,Edit,Write` writes only inside the cwd; `codex exec -s
21
+ read-only` refuses and `-s workspace-write` writes only inside the cwd.
22
+
23
+ Carried from the transport's predecessors, because they were measured:
24
+ `env_unset` for the Claude Code CLI, which refuses to nest inside itself
25
+ while `CLAUDECODE` is set; executable candidates beyond PATH (npm and WinGet
26
+ shims for codex, `~/.local/bin` for claude); a `--version` check for a shim
27
+ that can point at a deleted binary; and the streaming reader with its
28
+ kill loop. The prompt always travels on stdin -- the argv length limit that
29
+ once forced a switch at 8000 bytes only ever applied to the argv path, and
30
+ both CLIs read stdin.
31
+
32
+ **Run while alive** (2026-09-18): every call goes through the streaming
33
+ reader, whether or not the caller wants the lines echoed, because the lines
34
+ are the sign of life. A call ends when the child exits, when nothing has
35
+ arrived for `Request.idle` seconds (the child is declared dead and killed,
36
+ and the sentence says how long it was silent), or when the caller set a
37
+ ceiling in `Request.timeout` and the whole call ran past it. Measured before
38
+ this was built: the deep step on Claude Opus over a real file was cut at a
39
+ fixed 120 s while its transcript showed it reading and thinking the whole
40
+ time; with `--output-format stream-json --include-partial-messages` its
41
+ thinking arrived as an event every second or so, the longest silence 1.6 s.
42
+ The `stream-json:<key>` answer locator reads that format: the answer is one
43
+ key of the last `result` event, and an `is_error` result is a failure
44
+ carrying its text.
45
+ """
46
+ from __future__ import annotations
47
+
48
+ import json
49
+ import os
50
+ import shutil
51
+ import subprocess
52
+ import sys
53
+ import tempfile
54
+ import threading
55
+ import time
56
+ from pathlib import Path
57
+
58
+ from ..types import MODEL, ON_PREM, SCHEMA, STREAM, TOOLS, Readiness, Request, Response, Spec
59
+
60
+ _PLACEHOLDERS = ("{model}", "{schema}", "{schema_file}", "{prompt_file}", "{output_file}", "{cwd}", "{tools}",
61
+ "{turns}")
62
+
63
+
64
+ class _Stopped(Exception):
65
+ """The child was killed by this transport, and the sentence says why."""
66
+
67
+
68
+ def find_exe(name: str, candidates: tuple[str, ...] = ()) -> str | None:
69
+ """The executable to run: `name` if it is a path that exists, else PATH
70
+ (with the `.exe` / `.cmd` variants `shutil.which` knows), else the first
71
+ candidate that exists. A `.cmd` shim on Windows is run by full path."""
72
+ if not name:
73
+ return None
74
+ p = Path(name).expanduser()
75
+ if p.is_file():
76
+ return str(p)
77
+ hit = shutil.which(name) or (sys.platform == "win32" and (shutil.which(name + ".exe") or shutil.which(name + ".cmd")))
78
+ if hit:
79
+ return str(hit)
80
+ for c in candidates:
81
+ cp = Path(os.path.expandvars(c)).expanduser()
82
+ if cp.is_file():
83
+ return str(cp)
84
+ return None
85
+
86
+
87
+ def _validated(exe: str) -> bool:
88
+ """A shim can point at a deleted binary; `--version` proves it runs."""
89
+ try:
90
+ return subprocess.run([exe, "--version"], capture_output=True, text=True, timeout=10).returncode == 0
91
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
92
+ return False
93
+
94
+
95
+ class SubprocessCli:
96
+ """Stateless: every method takes the spec it is working for."""
97
+
98
+ def capabilities(self, spec: Spec) -> frozenset[str]:
99
+ caps = {STREAM}
100
+ tpl = " ".join(spec.command)
101
+ if "{model}" in tpl:
102
+ caps.add(MODEL)
103
+ if "{schema}" in tpl or "{schema_file}" in tpl:
104
+ caps.add(SCHEMA)
105
+ if "{tools}" in tpl and spec.tools:
106
+ caps.add(TOOLS)
107
+ if spec.on_prem:
108
+ caps.add(ON_PREM)
109
+ return frozenset(caps)
110
+
111
+ def probe(self, spec: Spec) -> Readiness:
112
+ name = spec.command[0] if spec.command else ""
113
+ exe = find_exe(name, spec.candidates)
114
+ if not exe:
115
+ return Readiness(False, f"{name or '(no command)'} is not on PATH" + (f" -- {spec.hint}" if spec.hint else ""))
116
+ if exe.lower().endswith((".cmd", ".bat")) and not _validated(exe):
117
+ return Readiness(False, f"{exe} exists but does not run (a shim to a deleted binary?)")
118
+ return Readiness(True, f"its CLI was found: {exe}")
119
+
120
+ # -- argv -----------------------------------------------------------------------
121
+
122
+ def _argv(self, spec: Spec, req: Request, exe: str, scratch: Path) -> tuple[list[str], dict, list[str]]:
123
+ """The template substituted. An empty placeholder removes its own
124
+ token AND the flag before it, so `--model {model}` with no model
125
+ never becomes `--model ""` -- and `{tools}` is empty unless the
126
+ request names a working directory, so a grant never reaches a call
127
+ that gave the child nothing to work in. Returns (argv, files-made,
128
+ honoured)."""
129
+ values = {
130
+ "{model}": spec.model,
131
+ "{schema}": json.dumps(req.schema) if req.schema is not None else "",
132
+ "{schema_file}": "", "{prompt_file}": "", "{output_file}": "",
133
+ "{cwd}": req.workdir or str(scratch),
134
+ "{tools}": spec.tools if req.workdir else "",
135
+ "{turns}": str(spec.max_turns) if spec.max_turns else "",
136
+ }
137
+ made: dict[str, Path] = {}
138
+ tpl = " ".join(spec.command)
139
+ if "{schema_file}" in tpl and req.schema is not None:
140
+ made["schema"] = scratch / "schema.json"
141
+ made["schema"].write_text(json.dumps(req.schema), encoding="utf-8")
142
+ values["{schema_file}"] = str(made["schema"])
143
+ if "{prompt_file}" in tpl:
144
+ made["prompt"] = scratch / "prompt.md"
145
+ made["prompt"].write_text(req.prompt, encoding="utf-8")
146
+ values["{prompt_file}"] = str(made["prompt"])
147
+ if "{output_file}" in tpl:
148
+ made["output"] = scratch / "answer.out"
149
+ values["{output_file}"] = str(made["output"])
150
+ out: list[str] = [exe]
151
+ used: set[str] = set()
152
+ for tok in spec.command[1:]:
153
+ holes = [h for h in _PLACEHOLDERS if h in tok]
154
+ if not holes:
155
+ out.append(tok)
156
+ continue
157
+ if any(values[h] == "" for h in holes):
158
+ if out and out[-1].startswith("-") and out[-1] != exe:
159
+ out.pop()
160
+ continue
161
+ for h in holes:
162
+ tok = tok.replace(h, values[h])
163
+ used.add(h)
164
+ out.append(tok)
165
+ honoured = []
166
+ if "{model}" in used and spec.model:
167
+ honoured.append(MODEL)
168
+ if ({"{schema}", "{schema_file}"} & used) and req.schema is not None:
169
+ honoured.append(SCHEMA)
170
+ if "{tools}" in used:
171
+ honoured.append(TOOLS)
172
+ return out, made, honoured
173
+
174
+ # -- running ----------------------------------------------------------------------
175
+
176
+ def invoke(self, spec: Spec, req: Request) -> Response:
177
+ name = spec.command[0] if spec.command else ""
178
+ exe = find_exe(name, spec.candidates)
179
+ if not exe:
180
+ return Response("failed", error=f"{name or '(no command)'} is not on PATH")
181
+ scratch = Path(tempfile.mkdtemp(prefix="ailib-cli-"))
182
+ # The child's cwd: the caller's working directory when the request
183
+ # names one (kept afterwards, it is theirs), else the scratch.
184
+ cwd = Path(req.workdir) if req.workdir else scratch
185
+ try:
186
+ argv, made, honoured = self._argv(spec, req, exe, scratch)
187
+ env = dict(os.environ)
188
+ for k in spec.env_unset:
189
+ env.pop(k, None)
190
+ stdin = None if "prompt" in made else req.prompt
191
+ started = time.monotonic()
192
+ try:
193
+ # Always the streaming reader: the lines are the sign of life
194
+ # the idle rule watches, echoed or not.
195
+ rc, out, err = self._stream(name, argv, env, cwd, stdin, req)
196
+ if req.stream_to is not None:
197
+ honoured.append(STREAM)
198
+ except _Stopped as e:
199
+ return Response("failed", error=str(e), elapsed=time.monotonic() - started)
200
+ except (FileNotFoundError, OSError) as e:
201
+ return Response("failed", error=f"cannot run {exe}: {e}")
202
+ elapsed = time.monotonic() - started
203
+ if rc != 0:
204
+ detail = (err or out or "").strip()
205
+ return Response("failed", elapsed=elapsed,
206
+ error=f"{name} exited with code {rc}" + (f": {detail[:400]}" if detail else ""))
207
+ text, why = self._locate(spec, out, made)
208
+ if why:
209
+ return Response("failed", error=why, elapsed=elapsed)
210
+ return Response("answered", text=text, model_used=spec.model, honoured=tuple(honoured),
211
+ elapsed=elapsed)
212
+ finally:
213
+ shutil.rmtree(scratch, ignore_errors=True)
214
+
215
+ def _locate(self, spec: Spec, stdout: str, made: dict[str, Path]) -> tuple[str, str]:
216
+ """Where the answer is, per `Spec.answer`."""
217
+ where = spec.answer or "stdout"
218
+ if where == "stdout":
219
+ return stdout, ""
220
+ if where.startswith("stdout-json:"):
221
+ key = where.split(":", 1)[1]
222
+ body = stdout.strip()
223
+ try:
224
+ obj = json.loads(body)
225
+ except ValueError:
226
+ try: # a chatty CLI: the last line
227
+ obj = json.loads(body.splitlines()[-1]) if body else None
228
+ except ValueError:
229
+ obj = None
230
+ if not isinstance(obj, dict):
231
+ return "", f"{spec.command[0]} did not print a JSON envelope on stdout"
232
+ if key not in obj:
233
+ return "", f"the JSON envelope has no key {key!r} (it has: {', '.join(obj)})"
234
+ val = obj[key]
235
+ return (val if isinstance(val, str) else json.dumps(val)), ""
236
+ if where.startswith("stream-json:"):
237
+ key = where.split(":", 1)[1]
238
+ for raw in reversed(stdout.splitlines()): # the result is the LAST event
239
+ line = raw.strip()
240
+ if not line.startswith("{"):
241
+ continue
242
+ try:
243
+ obj = json.loads(line)
244
+ except ValueError:
245
+ continue
246
+ if not isinstance(obj, dict) or obj.get("type") != "result":
247
+ continue
248
+ if obj.get("is_error"):
249
+ said = obj.get(key) if isinstance(obj.get(key), str) else obj.get("result")
250
+ return "", f"{spec.command[0]} reported an error: {str(said or obj.get('subtype') or '')[:400]}"
251
+ if key not in obj:
252
+ return "", f"the result event has no key {key!r} (it has: {', '.join(obj)})"
253
+ val = obj[key]
254
+ return (val if isinstance(val, str) else json.dumps(val)), ""
255
+ return "", f"{spec.command[0]} printed no result event on stdout"
256
+ if where.startswith("file:"):
257
+ path = made.get("output")
258
+ if path is None:
259
+ return "", "answer is 'file:{output_file}' but the template has no {output_file}"
260
+ try:
261
+ return path.read_text(encoding="utf-8"), ""
262
+ except OSError as e:
263
+ return "", f"{spec.command[0]} wrote no answer file: {e}"
264
+ return "", f"unknown answer locator {where!r}"
265
+
266
+ def _stream(self, name: str, argv, env, cwd, stdin, req: Request):
267
+ """Read the child's output as it arrives (echoing each line to
268
+ `req.stream_to` when there is one), and end the call on silence or
269
+ on the caller's ceiling; returns (rc, output, '')."""
270
+ proc = subprocess.Popen(argv, env=env, cwd=str(cwd),
271
+ stdin=subprocess.PIPE if stdin is not None else None,
272
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
273
+ text=True, encoding="utf-8", errors="replace")
274
+ lines: list[str] = []
275
+ last = [time.monotonic()] # when the child last said anything
276
+
277
+ def _reader():
278
+ for line in proc.stdout:
279
+ lines.append(line)
280
+ last[0] = time.monotonic()
281
+ if req.stream_to is not None:
282
+ try:
283
+ req.stream_to.write(line)
284
+ req.stream_to.flush()
285
+ except (OSError, ValueError):
286
+ pass
287
+
288
+ # The reader drains stdout BEFORE the prompt is written, so a child
289
+ # that talks before it has read all of stdin cannot fill its pipe
290
+ # while this side is still blocked writing a large prompt.
291
+ t = threading.Thread(target=_reader, daemon=True)
292
+ t.start()
293
+ if stdin is not None:
294
+ try:
295
+ proc.stdin.write(stdin)
296
+ proc.stdin.close()
297
+ except (OSError, ValueError):
298
+ pass # the child exited early; its rc says so
299
+ start = time.monotonic()
300
+ while proc.poll() is None:
301
+ now = time.monotonic()
302
+ if req.idle and now - last[0] > req.idle:
303
+ proc.kill()
304
+ proc.wait()
305
+ raise _Stopped(f"{name} gave no sign of life for {req.idle}s -- stopped")
306
+ if req.timeout and now - start > req.timeout:
307
+ proc.kill()
308
+ proc.wait()
309
+ raise _Stopped(f"{name} ran past the {req.timeout}s ceiling you set -- stopped")
310
+ time.sleep(0.05)
311
+ t.join(timeout=5)
312
+ return proc.returncode, "".join(lines), ""