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.
- git_repokit-0.9.10.dist-info/METADATA +422 -0
- git_repokit-0.9.10.dist-info/RECORD +174 -0
- git_repokit-0.9.10.dist-info/WHEEL +5 -0
- git_repokit-0.9.10.dist-info/entry_points.txt +2 -0
- git_repokit-0.9.10.dist-info/licenses/LICENSE +9 -0
- git_repokit-0.9.10.dist-info/top_level.txt +1 -0
- repokit/__init__.py +8 -0
- repokit/__main__.py +12 -0
- repokit/__version__.py +4 -0
- repokit/_vendor/__init__.py +0 -0
- repokit/_vendor/ailib/__init__.py +1 -0
- repokit/_vendor/ailib/analyzer.py +177 -0
- repokit/_vendor/ailib/backend.py +95 -0
- repokit/_vendor/ailib/cache.py +97 -0
- repokit/_vendor/ailib/parsers.py +52 -0
- repokit/_vendor/ailib/transports/__init__.py +16 -0
- repokit/_vendor/ailib/transports/cli.py +312 -0
- repokit/_vendor/ailib/transports/openai_compat.py +383 -0
- repokit/_vendor/ailib/transports/prompt_file.py +41 -0
- repokit/_vendor/ailib/types.py +224 -0
- repokit/auth_integration.py +244 -0
- repokit/badges.py +183 -0
- repokit/branch_utils.py +364 -0
- repokit/cli.py +2997 -0
- repokit/common_kit.py +202 -0
- repokit/config.py +686 -0
- repokit/defaults.py +220 -0
- repokit/directory_analyzer.py +1244 -0
- repokit/directory_profiles.py +423 -0
- repokit/git_config.py +370 -0
- repokit/github_create.py +142 -0
- repokit/github_setup.py +248 -0
- repokit/guardrails.py +707 -0
- repokit/history_cleaner.py +449 -0
- repokit/history_protection.py +434 -0
- repokit/hooks/pre-commit-guardrails +122 -0
- repokit/hooks/pre-commit-template +28 -0
- repokit/layers.py +510 -0
- repokit/licenses/0bsd.txt +14 -0
- repokit/licenses/LICENSES.md +27 -0
- repokit/licenses/__init__.py +142 -0
- repokit/licenses/agpl-3.0.txt +661 -0
- repokit/licenses/all-rights-reserved.txt +10 -0
- repokit/licenses/apache-2.0.txt +201 -0
- repokit/licenses/bsd-2-clause.txt +24 -0
- repokit/licenses/bsd-3-clause.txt +28 -0
- repokit/licenses/bsl-1.0.txt +23 -0
- repokit/licenses/cc-by-4.0.txt +395 -0
- repokit/licenses/cc-by-sa-4.0.txt +427 -0
- repokit/licenses/cc0-1.0.txt +121 -0
- repokit/licenses/epl-2.0.txt +277 -0
- repokit/licenses/gpl-2.0.txt +339 -0
- repokit/licenses/gpl-3.0.txt +674 -0
- repokit/licenses/isc.txt +15 -0
- repokit/licenses/lgpl-2.1.txt +504 -0
- repokit/licenses/lgpl-3.0.txt +165 -0
- repokit/licenses/mit.txt +21 -0
- repokit/licenses/mpl-2.0.txt +373 -0
- repokit/licenses/unlicense.txt +24 -0
- repokit/readme_blocks.py +112 -0
- repokit/remote_integration.py +627 -0
- repokit/repo_manager.py +2796 -0
- repokit/template_engine.py +391 -0
- repokit/templates/ai/claude/CLAUDE-nested.md.template +25 -0
- repokit/templates/ai/claude/CLAUDE.md.template +98 -0
- repokit/templates/ai/claude/instructions/step1_context_rebuilder.md +33 -0
- repokit/templates/ai/claude/instructions/step2_dev_workflow_process.md +67 -0
- repokit/templates/ai/claude/instructions/step3_context_bridge.md +52 -0
- repokit/templates/common/CHANGELOG.md.template +15 -0
- repokit/templates/common/CONTRIBUTING.md.template +31 -0
- repokit/templates/common/README.md.template +30 -0
- repokit/templates/common/gitattributes.template +17 -0
- repokit/templates/common/gitignore.template +141 -0
- repokit/templates/extras/FUNDING.yml.template +13 -0
- repokit/templates/extras/ROADMAP.md.template +17 -0
- repokit/templates/extras/dependabot.yml.template +22 -0
- repokit/templates/extras/one-offs-README.md.template +28 -0
- repokit/templates/extras/platform-support.md.template +11 -0
- repokit/templates/extras/pull_request_template.md.template +25 -0
- repokit/templates/extras/release-template.md.template +34 -0
- repokit/templates/extras/settings.json.template +11 -0
- repokit/templates/extras/stale.yml.template +14 -0
- repokit/templates/github/CODEOWNERS.template +11 -0
- repokit/templates/github/ISSUE_TEMPLATE/bug-report.md.template +29 -0
- repokit/templates/github/ISSUE_TEMPLATE/feature-request.md.template +19 -0
- repokit/templates/github/issues/notes.md.template +18 -0
- repokit/templates/github/issues/roadmap.md.template +15 -0
- repokit/templates/hooks/pre-commit.template +64 -0
- repokit/templates/layers/archetypes/any/claude-plugin/.claude-plugin/marketplace.json +20 -0
- repokit/templates/layers/archetypes/any/claude-plugin/plugin/.claude-plugin/plugin.json +15 -0
- repokit/templates/layers/archetypes/any/claude-plugin/plugin/README.md +28 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/.github/workflows/ci-c-cpp.yml +24 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/.repokit-common.toml.rkappend +4 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/.vscode/launch.json +29 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/.vscode/tasks.json +26 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/CMakeLists.txt +47 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/CMakePresets.json +23 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/cmake/GenerateStamp.cmake +45 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/src/main.cpp +27 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/tests/test_version.cpp +20 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/vcpkg.json +5 -0
- repokit/templates/layers/archetypes/c-cpp/minimal/.vscode/launch.json +27 -0
- repokit/templates/layers/archetypes/c-cpp/minimal/Makefile +10 -0
- repokit/templates/layers/archetypes/c-cpp/minimal/build.bat +7 -0
- repokit/templates/layers/archetypes/c-cpp/minimal/src/__RK_SNAKE__.c +26 -0
- repokit/templates/layers/archetypes/c-cpp/msbuild/.vscode/launch.json +17 -0
- repokit/templates/layers/archetypes/c-cpp/msbuild/.vscode/tasks.json +20 -0
- repokit/templates/layers/archetypes/c-cpp/msbuild/__RK_PROJECT__.sln +19 -0
- repokit/templates/layers/archetypes/c-cpp/msbuild/__RK_PROJECT__.vcxproj +41 -0
- repokit/templates/layers/archetypes/c-cpp/msbuild/src/main.cpp +26 -0
- repokit/templates/layers/archetypes/js-ts/claude-plugin/.repokit-common.toml +28 -0
- repokit/templates/layers/archetypes/js-ts/claude-plugin/.vscode/launch.json +34 -0
- repokit/templates/layers/archetypes/js-ts/claude-plugin/plugin/hooks/hooks.json +15 -0
- repokit/templates/layers/archetypes/js-ts/claude-plugin/plugin/hooks/scripts/session-start.mjs +23 -0
- repokit/templates/layers/archetypes/js-ts/claude-plugin/plugin/tests/manifest.test.js +30 -0
- repokit/templates/layers/archetypes/js-ts/node/.repokit-common.toml.rkappend +6 -0
- repokit/templates/layers/archetypes/js-ts/node/package.json +13 -0
- repokit/templates/layers/archetypes/js-ts/node-package/.github/workflows/release.yml +39 -0
- repokit/templates/layers/archetypes/js-ts/node-package/package.json +32 -0
- repokit/templates/layers/archetypes/js-ts/node-package/src/index.ts +13 -0
- repokit/templates/layers/archetypes/js-ts/node-package/tests/index.test.ts +11 -0
- repokit/templates/layers/archetypes/js-ts/script/.vscode/launch.json +34 -0
- repokit/templates/layers/archetypes/js-ts/script/__RK_PROJECT__.js +37 -0
- repokit/templates/layers/archetypes/js-ts/script/tests/version.test.js +26 -0
- repokit/templates/layers/archetypes/js-ts/ts/package.json +18 -0
- repokit/templates/layers/archetypes/js-ts/ts/tsconfig.json +15 -0
- repokit/templates/layers/archetypes/python/claude-plugin/.github/workflows/ci-python.yml +44 -0
- repokit/templates/layers/archetypes/python/claude-plugin/.repokit-common.toml +21 -0
- repokit/templates/layers/archetypes/python/claude-plugin/.vscode/launch.json +33 -0
- repokit/templates/layers/archetypes/python/claude-plugin/VERSION +1 -0
- repokit/templates/layers/archetypes/python/claude-plugin/plugin/hooks/hooks.json +15 -0
- repokit/templates/layers/archetypes/python/claude-plugin/plugin/hooks/scripts/run-hook.mjs +42 -0
- repokit/templates/layers/archetypes/python/claude-plugin/plugin/hooks/scripts/session_start.py +31 -0
- repokit/templates/layers/archetypes/python/claude-plugin/plugin/tests/test_manifest.py +42 -0
- repokit/templates/layers/archetypes/python/pypi/.github/workflows/release.yml +63 -0
- repokit/templates/layers/archetypes/python/pypi/.vscode/launch.json +43 -0
- repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/__init__.py +5 -0
- repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/__main__.py +6 -0
- repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/_version.py +96 -0
- repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/cli.py +18 -0
- repokit/templates/layers/archetypes/python/pypi/pyproject.toml +71 -0
- repokit/templates/layers/archetypes/python/pypi/tests/conftest.py +11 -0
- repokit/templates/layers/archetypes/python/pypi/tests/test_version.py +50 -0
- repokit/templates/layers/archetypes/python/script/.github/workflows/ci-python.yml +43 -0
- repokit/templates/layers/archetypes/python/script/.repokit-common.toml +14 -0
- repokit/templates/layers/archetypes/python/script/.vscode/launch.json +33 -0
- repokit/templates/layers/archetypes/python/script/VERSION +1 -0
- repokit/templates/layers/archetypes/python/script/__RK_PROJECT__.py +40 -0
- repokit/templates/layers/archetypes/python/script/tests/test_version.py +25 -0
- repokit/templates/layers/archetypes/python/setuppy/requirements.txt +8 -0
- repokit/templates/layers/archetypes/python/setuppy/setup.py +23 -0
- repokit/templates/layers/languages/c-cpp/.gitignore.rkappend +20 -0
- repokit/templates/layers/languages/c-cpp/.repokit-common.toml +7 -0
- repokit/templates/layers/languages/c-cpp/include/__RK_SNAKE__/version.h +20 -0
- repokit/templates/layers/languages/c-cpp/src/app.rc +25 -0
- repokit/templates/layers/languages/js-ts/.editorconfig +15 -0
- repokit/templates/layers/languages/js-ts/.github/workflows/ci-js-ts.yml +41 -0
- repokit/templates/layers/languages/js-ts/.gitignore.rkappend +13 -0
- repokit/templates/layers/languages/js-ts/.repokit-common.toml +19 -0
- repokit/templates/layers/languages/js-ts/.vscode/launch.json +24 -0
- repokit/templates/layers/languages/js-ts/VERSION +1 -0
- repokit/templates/layers/languages/python/.github/workflows/ci-python.yml +76 -0
- repokit/templates/layers/languages/python/.gitignore.rkappend +15 -0
- repokit/templates/layers/languages/python/.vscode/launch.json +33 -0
- repokit/templates/layers/manifest.toml +302 -0
- repokit/templates/vault/CLAUDE.md +29 -0
- repokit/templates/vault/claude/_maps/MOC-project.md +15 -0
- repokit/templates/vault/claude/_oracle/backlinks.md +3 -0
- repokit/templates/vault/claude/_oracle/concepts.md +12 -0
- repokit/templates/vault/claude/_oracle/manifest.md +10 -0
- repokit/templates/vault/claude/_todo/ACTIVE-WORK.md +18 -0
- repokit/templates/vault/claude/_todo/OPEN-LOOPS.md +14 -0
- repokit/utils.py +316 -0
- 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), ""
|