genesis-cli 0.1.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {genesis_cli-0.1.0 → genesis_cli-0.3.0}/PKG-INFO +4 -3
- {genesis_cli-0.1.0 → genesis_cli-0.3.0}/pyproject.toml +4 -2
- {genesis_cli-0.1.0 → genesis_cli-0.3.0}/src/genesis_cli/adopt.py +8 -1
- {genesis_cli-0.1.0 → genesis_cli-0.3.0}/src/genesis_cli/init.py +7 -0
- {genesis_cli-0.1.0 → genesis_cli-0.3.0}/src/genesis_cli/main.py +11 -0
- genesis_cli-0.3.0/src/genesis_cli/runner/__init__.py +11 -0
- genesis_cli-0.3.0/src/genesis_cli/runner/claude_driver.py +319 -0
- genesis_cli-0.3.0/src/genesis_cli/runner/client.py +111 -0
- genesis_cli-0.3.0/src/genesis_cli/runner/config.py +62 -0
- genesis_cli-0.3.0/src/genesis_cli/runner/loop.py +309 -0
- genesis_cli-0.3.0/src/genesis_cli/runner/runner_cmd.py +53 -0
- genesis_cli-0.3.0/src/genesis_cli/runner/workspace.py +90 -0
- {genesis_cli-0.1.0 → genesis_cli-0.3.0}/tests/test_adopt.py +17 -0
- genesis_cli-0.3.0/tests/test_runner_completion.py +167 -0
- genesis_cli-0.3.0/tests/test_runner_config.py +55 -0
- genesis_cli-0.3.0/tests/test_runner_driver.py +135 -0
- genesis_cli-0.3.0/tests/test_runner_workspace.py +103 -0
- {genesis_cli-0.1.0 → genesis_cli-0.3.0}/.gitignore +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.3.0}/LICENSE +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.3.0}/README.md +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.3.0}/action/README.md +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.3.0}/action/action.yml +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.3.0}/src/genesis_cli/__init__.py +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.3.0}/src/genesis_cli/agents_md.py +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.3.0}/src/genesis_cli/baseline.py +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.3.0}/src/genesis_cli/gates.py +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.3.0}/src/genesis_cli/report.py +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.3.0}/src/genesis_cli/review.py +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.3.0}/src/genesis_cli/sarif.py +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.3.0}/tests/test_baseline.py +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.3.0}/tests/test_gates_cmd.py +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.3.0}/tests/test_init.py +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.3.0}/tests/test_review.py +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.3.0}/tests/test_sarif.py +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.3.0}/tests/test_schemas.py +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.3.0}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: genesis-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Genesis Agent Workbench CLI — make any repo agent-ready and keep AI-generated changes governed.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Inceptium-ai/genesis-framework
|
|
6
6
|
Project-URL: Repository, https://github.com/Inceptium-ai/genesis-framework
|
|
@@ -20,7 +20,8 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
20
20
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
21
21
|
Classifier: Topic :: Software Development :: Testing
|
|
22
22
|
Requires-Python: >=3.12
|
|
23
|
-
Requires-Dist: genesis-gates>=0.
|
|
23
|
+
Requires-Dist: genesis-gates>=0.3.0
|
|
24
|
+
Requires-Dist: httpx>=0.27
|
|
24
25
|
Requires-Dist: pyyaml>=6.0
|
|
25
26
|
Description-Content-Type: text/markdown
|
|
26
27
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "genesis-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Genesis Agent Workbench CLI — make any repo agent-ready and keep AI-generated changes governed."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -30,8 +30,10 @@ dependencies = [
|
|
|
30
30
|
# Must stay a REAL version requirement: `[tool.uv.sources]` below is a
|
|
31
31
|
# dev-only workspace override and is not written into published metadata,
|
|
32
32
|
# so this is what `pip install genesis-cli` resolves against on PyPI.
|
|
33
|
-
"genesis-gates>=0.
|
|
33
|
+
"genesis-gates>=0.3.0",
|
|
34
34
|
"pyyaml>=6.0",
|
|
35
|
+
# `genesis runner` talks to a Genesis server over HTTP.
|
|
36
|
+
"httpx>=0.27",
|
|
35
37
|
]
|
|
36
38
|
|
|
37
39
|
[project.urls]
|
|
@@ -17,6 +17,7 @@ import genesis_gates.providers # noqa: F401
|
|
|
17
17
|
from genesis_gates import GateContext, GateRunner, get_gate, load_gate_config
|
|
18
18
|
from genesis_gates.analysis import (
|
|
19
19
|
analyze,
|
|
20
|
+
declared_env_vars,
|
|
20
21
|
reconcile,
|
|
21
22
|
repo_input,
|
|
22
23
|
score_readiness,
|
|
@@ -56,7 +57,13 @@ def run_audit(repo: Path) -> dict:
|
|
|
56
57
|
repo = repo.resolve()
|
|
57
58
|
gate_results = run_gates(repo).get("gate_results", [])
|
|
58
59
|
completeness = analyze(repo_input(repo))
|
|
59
|
-
|
|
60
|
+
# A brownfield audit has no stored dependency map, so the repo's own
|
|
61
|
+
# `.env.example`-family files ARE its declared config contract. Passing
|
|
62
|
+
# them makes env_hygiene measure "are env vars declared?"; with an empty
|
|
63
|
+
# map, every compose `${VAR}` without a default counted as an undeclared
|
|
64
|
+
# gap even when the repo documents it properly, which pinned the category
|
|
65
|
+
# near zero for any repo with an undefaulted compose reference.
|
|
66
|
+
reconciliation = reconcile(repo, stored_deps=declared_env_vars(repo))
|
|
60
67
|
facts = structure_facts(repo)
|
|
61
68
|
readiness = score_readiness(
|
|
62
69
|
gate_results=gate_results, facts=facts,
|
|
@@ -104,6 +104,13 @@ kind: GateConfig
|
|
|
104
104
|
# dependency-scan:
|
|
105
105
|
# provider: trivy
|
|
106
106
|
# fail_on_severity: critical
|
|
107
|
+
# # Trees this repo does not author and is not accountable for: vendored
|
|
108
|
+
# # dependencies, generated output, packaged sample apps that are scanned
|
|
109
|
+
# # in their own right. Repo-relative, anchored at the root ("vendor" does
|
|
110
|
+
# # not match "packages/x/vendor"). This is SCOPE, not suppression — a
|
|
111
|
+
# # skipped directory is invisible to the gate, so justify each entry in a
|
|
112
|
+
# # comment and keep your own source, infra and CI in scope.
|
|
113
|
+
# skip_dirs: [vendor, third_party/generated]
|
|
107
114
|
# unit-tests:
|
|
108
115
|
# provider: command
|
|
109
116
|
# command: "npm test"
|
|
@@ -9,6 +9,12 @@ Commands:
|
|
|
9
9
|
genesis init [path] Scaffold a new/bare repo so agents start agent-ready:
|
|
10
10
|
AGENTS.md, .gitignore, .env.example, audit workflow,
|
|
11
11
|
gate config. Idempotent — never overwrites.
|
|
12
|
+
genesis runner … Local runner for the Genesis platform: `connect` pairs
|
|
13
|
+
this machine with a server (runner token), `start`
|
|
14
|
+
executes your builds' coding-agent tasks here through
|
|
15
|
+
your own `claude` CLI (your subscription), `status`
|
|
16
|
+
shows the connection. The server keeps orchestration,
|
|
17
|
+
validation, gates, evidence, and billing.
|
|
12
18
|
genesis --version Print the installed genesis-cli version.
|
|
13
19
|
"""
|
|
14
20
|
|
|
@@ -220,6 +226,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
220
226
|
help="directory to scaffold (default: .; created if missing)")
|
|
221
227
|
init.set_defaults(func=_cmd_init)
|
|
222
228
|
|
|
229
|
+
# Local runner (deferred imports: the subcommands import httpx lazily).
|
|
230
|
+
from .runner.runner_cmd import add_runner_parser
|
|
231
|
+
|
|
232
|
+
add_runner_parser(sub)
|
|
233
|
+
|
|
223
234
|
args = parser.parse_args(argv)
|
|
224
235
|
return args.func(args)
|
|
225
236
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""`genesis runner` — execute your Genesis coding-agent tasks locally.
|
|
2
|
+
|
|
3
|
+
Connect this machine to a Genesis server with a runner token and your builds'
|
|
4
|
+
agent tasks run HERE, through your own local `claude` CLI (your subscription)
|
|
5
|
+
— while the server keeps all orchestration, validation, repair policy,
|
|
6
|
+
commits, gates, evidence, and billing.
|
|
7
|
+
|
|
8
|
+
Public package rule: this code never imports genesis_server or
|
|
9
|
+
genesis_executor; the wire protocol (claim / workspace / events / result)
|
|
10
|
+
is its only contract with the server.
|
|
11
|
+
"""
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Headless `claude` driver — the public runner-side reimplementation of the
|
|
2
|
+
platform's stream-json adapter (same argv, same parsing, no shared code: this
|
|
3
|
+
package never imports the commercial packages).
|
|
4
|
+
|
|
5
|
+
Runs `claude -p <prompt> --output-format stream-json` as an argv subprocess
|
|
6
|
+
(never a shell) with cwd = the task workspace. The child env is a pass-through
|
|
7
|
+
of the login environment with ANTHROPIC_API_KEY explicitly UNSET so the CLI
|
|
8
|
+
uses the user's subscription auth and never silently bills an API key.
|
|
9
|
+
|
|
10
|
+
Planning completions (0.3.0) use the same CLI in ONE-SHOT print mode —
|
|
11
|
+
`claude -p <prompt> --output-format json --system-prompt <system> --tools ""
|
|
12
|
+
--no-session-persistence` — run in a private empty directory with all tools
|
|
13
|
+
disabled: a pure text-in/text-out LLM call, parsed from the single result
|
|
14
|
+
JSON object (any non-JSON wrapper noise stripped).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
import tempfile
|
|
25
|
+
import time
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
# stream-json emits one JSON object per line; a single line (a large tool
|
|
30
|
+
# result or a long assistant turn) routinely exceeds asyncio's default 64 KB
|
|
31
|
+
# StreamReader line cap, which would raise LimitOverrunError mid-task. We read
|
|
32
|
+
# raw chunks and split on newlines ourselves (no per-line cap), and raise the
|
|
33
|
+
# subprocess buffer limit so the transport isn't paused every 64 KB.
|
|
34
|
+
_STREAM_LIMIT = 64 * 1024 * 1024 # 64 MB StreamReader buffer
|
|
35
|
+
_READ_CHUNK = 256 * 1024 # 256 KB per read
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def _iter_json_lines(stream: asyncio.StreamReader):
|
|
39
|
+
"""Yield complete decoded lines via chunked reads — an oversized line can
|
|
40
|
+
never raise asyncio.LimitOverrunError."""
|
|
41
|
+
buf = bytearray()
|
|
42
|
+
while True:
|
|
43
|
+
chunk = await stream.read(_READ_CHUNK)
|
|
44
|
+
if not chunk:
|
|
45
|
+
break
|
|
46
|
+
buf.extend(chunk)
|
|
47
|
+
while True:
|
|
48
|
+
nl = buf.find(b"\n")
|
|
49
|
+
if nl == -1:
|
|
50
|
+
break
|
|
51
|
+
line = bytes(buf[:nl])
|
|
52
|
+
del buf[: nl + 1]
|
|
53
|
+
yield line
|
|
54
|
+
if buf:
|
|
55
|
+
yield bytes(buf)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _assistant_text(evt: dict) -> str:
|
|
59
|
+
msg = evt.get("message") or {}
|
|
60
|
+
parts = msg.get("content") or []
|
|
61
|
+
if isinstance(parts, str):
|
|
62
|
+
return parts
|
|
63
|
+
return "".join(
|
|
64
|
+
p.get("text", "")
|
|
65
|
+
for p in parts
|
|
66
|
+
if isinstance(p, dict) and p.get("type") == "text"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _flatten_messages(messages: list[dict]) -> str:
|
|
71
|
+
"""One-shot print mode takes a single prompt, so a conversation (e.g. the
|
|
72
|
+
server's schema-repair retry: user, assistant, user) is flattened with
|
|
73
|
+
role tags. A single user message passes through untagged."""
|
|
74
|
+
if len(messages) == 1 and messages[0].get("role") == "user":
|
|
75
|
+
return str(messages[0].get("content") or "")
|
|
76
|
+
parts = []
|
|
77
|
+
for m in messages:
|
|
78
|
+
role = str(m.get("role") or "user").upper()
|
|
79
|
+
parts.append(f"[{role}]\n{m.get('content') or ''}")
|
|
80
|
+
return "\n\n".join(parts)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _final_json_object(stdout: str) -> dict | None:
|
|
84
|
+
"""The result object from `--output-format json` stdout, tolerating any
|
|
85
|
+
non-JSON wrapper lines (update notices, MOTD) before or after it."""
|
|
86
|
+
text = stdout.strip()
|
|
87
|
+
try:
|
|
88
|
+
obj = json.loads(text)
|
|
89
|
+
if isinstance(obj, dict):
|
|
90
|
+
return obj
|
|
91
|
+
except json.JSONDecodeError:
|
|
92
|
+
pass
|
|
93
|
+
# Line-wise, last parseable object wins (the result is printed last).
|
|
94
|
+
for line in reversed(text.splitlines()):
|
|
95
|
+
line = line.strip()
|
|
96
|
+
if not (line.startswith("{") and line.endswith("}")):
|
|
97
|
+
continue
|
|
98
|
+
try:
|
|
99
|
+
obj = json.loads(line)
|
|
100
|
+
if isinstance(obj, dict):
|
|
101
|
+
return obj
|
|
102
|
+
except json.JSONDecodeError:
|
|
103
|
+
continue
|
|
104
|
+
# Last resort: scan for embedded objects; the last decodable one wins.
|
|
105
|
+
dec = json.JSONDecoder()
|
|
106
|
+
best: dict | None = None
|
|
107
|
+
i = text.find("{")
|
|
108
|
+
while i != -1:
|
|
109
|
+
try:
|
|
110
|
+
obj, end = dec.raw_decode(text, i)
|
|
111
|
+
except json.JSONDecodeError:
|
|
112
|
+
i = text.find("{", i + 1)
|
|
113
|
+
continue
|
|
114
|
+
if isinstance(obj, dict):
|
|
115
|
+
best = obj
|
|
116
|
+
i = text.find("{", end)
|
|
117
|
+
return best
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def check_cli() -> tuple[bool, str]:
|
|
121
|
+
"""Preflight: is a working `claude` CLI on PATH?"""
|
|
122
|
+
if not shutil.which("claude"):
|
|
123
|
+
return False, (
|
|
124
|
+
"The `claude` CLI is not on PATH. Install Claude Code "
|
|
125
|
+
"(https://claude.com/claude-code) and sign in with your "
|
|
126
|
+
"subscription, then run `genesis runner start` again."
|
|
127
|
+
)
|
|
128
|
+
try:
|
|
129
|
+
proc = subprocess.run(["claude", "--version"], capture_output=True,
|
|
130
|
+
text=True, timeout=30)
|
|
131
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
132
|
+
return False, f"`claude --version` failed to run: {exc}"
|
|
133
|
+
if proc.returncode != 0:
|
|
134
|
+
return False, (f"`claude --version` exited {proc.returncode}: "
|
|
135
|
+
f"{(proc.stderr or proc.stdout)[:200]}")
|
|
136
|
+
return True, (proc.stdout or "").strip()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ClaudeDriver:
|
|
140
|
+
"""One task = one run(). kill() aborts the live subprocess (server-side
|
|
141
|
+
cancel or a second Ctrl-C)."""
|
|
142
|
+
|
|
143
|
+
def __init__(self, model: str | None = None):
|
|
144
|
+
self.model = model
|
|
145
|
+
self._proc: asyncio.subprocess.Process | None = None
|
|
146
|
+
self.killed = False
|
|
147
|
+
|
|
148
|
+
def kill(self) -> None:
|
|
149
|
+
self.killed = True
|
|
150
|
+
if self._proc is not None and self._proc.returncode is None:
|
|
151
|
+
self._proc.kill()
|
|
152
|
+
|
|
153
|
+
def _child_env(self) -> dict:
|
|
154
|
+
env = dict(os.environ)
|
|
155
|
+
# Force subscription auth: never let an API key take over accidentally.
|
|
156
|
+
env.pop("ANTHROPIC_API_KEY", None)
|
|
157
|
+
return env
|
|
158
|
+
|
|
159
|
+
async def run(self, prompt: str, *, workdir: Path,
|
|
160
|
+
resume_session: str | None = None,
|
|
161
|
+
on_text: Callable[[str], None] | None = None) -> dict:
|
|
162
|
+
"""Run one task; returns {status, session_id, usage, error} parsed
|
|
163
|
+
from the CLI's final result message."""
|
|
164
|
+
started = time.monotonic()
|
|
165
|
+
argv = [
|
|
166
|
+
"claude", "-p", prompt,
|
|
167
|
+
"--output-format", "stream-json",
|
|
168
|
+
"--verbose",
|
|
169
|
+
"--add-dir", str(workdir),
|
|
170
|
+
"--permission-mode", "acceptEdits",
|
|
171
|
+
]
|
|
172
|
+
if self.model:
|
|
173
|
+
argv += ["--model", self.model]
|
|
174
|
+
if resume_session:
|
|
175
|
+
argv += ["--resume", resume_session]
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
proc = await asyncio.create_subprocess_exec(
|
|
179
|
+
*argv,
|
|
180
|
+
cwd=str(workdir),
|
|
181
|
+
env=self._child_env(),
|
|
182
|
+
stdout=asyncio.subprocess.PIPE,
|
|
183
|
+
stderr=asyncio.subprocess.PIPE,
|
|
184
|
+
limit=_STREAM_LIMIT,
|
|
185
|
+
)
|
|
186
|
+
except FileNotFoundError:
|
|
187
|
+
return {"status": "failed", "session_id": None,
|
|
188
|
+
"usage": {}, "error": "claude CLI not found"}
|
|
189
|
+
self._proc = proc
|
|
190
|
+
|
|
191
|
+
usage: dict = {"input_tokens": 0, "output_tokens": 0, "cost_usd": None,
|
|
192
|
+
"estimated": False, "duration_s": None}
|
|
193
|
+
session_id: str | None = None
|
|
194
|
+
status = "failed"
|
|
195
|
+
error: str | None = None
|
|
196
|
+
|
|
197
|
+
assert proc.stdout is not None
|
|
198
|
+
try:
|
|
199
|
+
async for raw in _iter_json_lines(proc.stdout):
|
|
200
|
+
line = raw.decode("utf-8", "replace").strip()
|
|
201
|
+
if not line:
|
|
202
|
+
continue
|
|
203
|
+
try:
|
|
204
|
+
evt = json.loads(line)
|
|
205
|
+
except json.JSONDecodeError:
|
|
206
|
+
continue
|
|
207
|
+
etype = evt.get("type")
|
|
208
|
+
if evt.get("session_id"):
|
|
209
|
+
session_id = evt["session_id"]
|
|
210
|
+
if etype == "assistant":
|
|
211
|
+
text = _assistant_text(evt)
|
|
212
|
+
if text and on_text is not None:
|
|
213
|
+
on_text(text)
|
|
214
|
+
elif etype == "result":
|
|
215
|
+
if evt.get("total_cost_usd") is not None:
|
|
216
|
+
usage["cost_usd"] = float(evt["total_cost_usd"])
|
|
217
|
+
u = evt.get("usage") or {}
|
|
218
|
+
usage["input_tokens"] = int(u.get("input_tokens", 0))
|
|
219
|
+
usage["output_tokens"] = int(u.get("output_tokens", 0))
|
|
220
|
+
status = "completed" if evt.get("subtype") == "success" else "failed"
|
|
221
|
+
if evt.get("is_error"):
|
|
222
|
+
error = str(evt.get("result", "cli error"))[:500]
|
|
223
|
+
await proc.wait()
|
|
224
|
+
except asyncio.CancelledError:
|
|
225
|
+
proc.kill()
|
|
226
|
+
raise
|
|
227
|
+
finally:
|
|
228
|
+
self._proc = None
|
|
229
|
+
|
|
230
|
+
if self.killed:
|
|
231
|
+
status = "interrupted"
|
|
232
|
+
error = error or "task aborted on the runner"
|
|
233
|
+
elif proc.returncode not in (0, None) and status != "completed":
|
|
234
|
+
status = "failed"
|
|
235
|
+
if error is None:
|
|
236
|
+
err = (await proc.stderr.read()).decode("utf-8", "replace") \
|
|
237
|
+
if proc.stderr else ""
|
|
238
|
+
error = err[:500] or f"claude exited {proc.returncode}"
|
|
239
|
+
|
|
240
|
+
usage["duration_s"] = time.monotonic() - started
|
|
241
|
+
return {"status": status, "session_id": session_id,
|
|
242
|
+
"usage": usage, "error": error}
|
|
243
|
+
|
|
244
|
+
async def run_completion(self, *, system: str | None, messages: list[dict],
|
|
245
|
+
max_tokens: int = 4096,
|
|
246
|
+
timeout_s: float = 150.0) -> dict:
|
|
247
|
+
"""One planning completion (T-047): run `claude` in one-shot print
|
|
248
|
+
mode and return {status, text, usage, error}. Verified invocation:
|
|
249
|
+
`claude -p <prompt> --output-format json --system-prompt <system>
|
|
250
|
+
--tools "" --no-session-persistence` — a single result JSON object on
|
|
251
|
+
stdout whose `result` field is the model's text. Tools are disabled
|
|
252
|
+
and cwd is a throwaway empty directory: a pure LLM call, nothing on
|
|
253
|
+
this machine is read or written. The CLI exposes no max-tokens flag;
|
|
254
|
+
the server bounds output through the prompt contract instead.
|
|
255
|
+
"""
|
|
256
|
+
del max_tokens # accepted for wire-payload symmetry; see docstring
|
|
257
|
+
started = time.monotonic()
|
|
258
|
+
prompt = _flatten_messages(messages)
|
|
259
|
+
argv = [
|
|
260
|
+
"claude", "-p", prompt,
|
|
261
|
+
"--output-format", "json",
|
|
262
|
+
"--tools", "",
|
|
263
|
+
"--no-session-persistence",
|
|
264
|
+
]
|
|
265
|
+
if system:
|
|
266
|
+
argv += ["--system-prompt", system]
|
|
267
|
+
if self.model:
|
|
268
|
+
argv += ["--model", self.model]
|
|
269
|
+
|
|
270
|
+
usage: dict = {"input_tokens": 0, "output_tokens": 0, "duration_s": None}
|
|
271
|
+
with tempfile.TemporaryDirectory(prefix="genesis-completion-") as empty:
|
|
272
|
+
try:
|
|
273
|
+
proc = await asyncio.create_subprocess_exec(
|
|
274
|
+
*argv,
|
|
275
|
+
cwd=empty,
|
|
276
|
+
env=self._child_env(),
|
|
277
|
+
stdout=asyncio.subprocess.PIPE,
|
|
278
|
+
stderr=asyncio.subprocess.PIPE,
|
|
279
|
+
limit=_STREAM_LIMIT,
|
|
280
|
+
)
|
|
281
|
+
except FileNotFoundError:
|
|
282
|
+
return {"status": "failed", "text": None, "usage": usage,
|
|
283
|
+
"error": "claude CLI not found"}
|
|
284
|
+
self._proc = proc
|
|
285
|
+
try:
|
|
286
|
+
out_b, err_b = await asyncio.wait_for(proc.communicate(),
|
|
287
|
+
timeout=timeout_s)
|
|
288
|
+
except TimeoutError:
|
|
289
|
+
proc.kill()
|
|
290
|
+
await proc.wait()
|
|
291
|
+
return {"status": "failed", "text": None, "usage": usage,
|
|
292
|
+
"error": f"completion timed out after {timeout_s:.0f}s"}
|
|
293
|
+
except asyncio.CancelledError:
|
|
294
|
+
proc.kill()
|
|
295
|
+
raise
|
|
296
|
+
finally:
|
|
297
|
+
self._proc = None
|
|
298
|
+
|
|
299
|
+
usage["duration_s"] = time.monotonic() - started
|
|
300
|
+
if self.killed:
|
|
301
|
+
return {"status": "interrupted", "text": None, "usage": usage,
|
|
302
|
+
"error": "completion aborted on the runner"}
|
|
303
|
+
|
|
304
|
+
evt = _final_json_object(out_b.decode("utf-8", "replace"))
|
|
305
|
+
if evt is None:
|
|
306
|
+
err = err_b.decode("utf-8", "replace")[:500]
|
|
307
|
+
return {"status": "failed", "text": None, "usage": usage,
|
|
308
|
+
"error": err or f"claude exited {proc.returncode} "
|
|
309
|
+
"with no result JSON"}
|
|
310
|
+
u = evt.get("usage") or {}
|
|
311
|
+
usage["input_tokens"] = int(u.get("input_tokens") or 0)
|
|
312
|
+
usage["output_tokens"] = int(u.get("output_tokens") or 0)
|
|
313
|
+
text = evt.get("result")
|
|
314
|
+
if evt.get("is_error") or evt.get("subtype") != "success" \
|
|
315
|
+
or not isinstance(text, str):
|
|
316
|
+
return {"status": "failed", "text": None, "usage": usage,
|
|
317
|
+
"error": str(evt.get("result") or "cli error")[:500]}
|
|
318
|
+
return {"status": "completed", "text": text, "usage": usage,
|
|
319
|
+
"error": None}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""HTTP client for the Genesis runner gateway.
|
|
2
|
+
|
|
3
|
+
One rule above all: the token is a credential — it goes into the
|
|
4
|
+
Authorization header and NOWHERE else (never a log line, never an error
|
|
5
|
+
message, never a URL).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
# The server long-polls a claim for ~25s; read must comfortably outlast that.
|
|
15
|
+
_CLAIM_TIMEOUT = httpx.Timeout(10.0, read=60.0)
|
|
16
|
+
_IO_TIMEOUT = httpx.Timeout(10.0, read=300.0)
|
|
17
|
+
|
|
18
|
+
# What this client can serve, advertised on every claim (0.3.0+). 'completion'
|
|
19
|
+
# = one-shot planning LLM calls through the local `claude` CLI, no workspace.
|
|
20
|
+
# Additive wire change: an 0.2.0 server has no body on /claim and ignores it;
|
|
21
|
+
# an 0.3.0 server hands completion items only to claims that advertise it.
|
|
22
|
+
CAPABILITIES = ("completion",)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RunnerAuthError(RuntimeError):
|
|
26
|
+
"""The server rejected the runner token (bad, rotated, or revoked)."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RunnerClient:
|
|
30
|
+
def __init__(self, server: str, token: str):
|
|
31
|
+
self._client = httpx.AsyncClient(
|
|
32
|
+
base_url=server.rstrip("/"),
|
|
33
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
34
|
+
timeout=_IO_TIMEOUT,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
async def aclose(self) -> None:
|
|
38
|
+
await self._client.aclose()
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def _check_auth(resp: httpx.Response) -> None:
|
|
42
|
+
if resp.status_code == 401:
|
|
43
|
+
raise RunnerAuthError(
|
|
44
|
+
"The server rejected this runner's token — it may have been "
|
|
45
|
+
"rotated or revoked. Run `genesis runner connect` again."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
async def probe(self) -> bool:
|
|
49
|
+
"""Side-effect-free auth check: a nonexistent work item reads 404 with
|
|
50
|
+
a valid token, 401 with a bad one (and never claims real work)."""
|
|
51
|
+
resp = await self._client.get("/api/runner/work/probe/workspace")
|
|
52
|
+
if resp.status_code == 401:
|
|
53
|
+
return False
|
|
54
|
+
return resp.status_code == 404
|
|
55
|
+
|
|
56
|
+
async def claim(self) -> dict | None:
|
|
57
|
+
"""One long-poll. None = no work this window (poll again)."""
|
|
58
|
+
resp = await self._client.post(
|
|
59
|
+
"/api/runner/claim", json={"capabilities": list(CAPABILITIES)},
|
|
60
|
+
timeout=_CLAIM_TIMEOUT,
|
|
61
|
+
)
|
|
62
|
+
self._check_auth(resp)
|
|
63
|
+
if resp.status_code == 204:
|
|
64
|
+
return None
|
|
65
|
+
resp.raise_for_status()
|
|
66
|
+
data = resp.json()
|
|
67
|
+
return data if isinstance(data, dict) and data.get("item_id") else None
|
|
68
|
+
|
|
69
|
+
async def download_workspace(self, item_id: str, dest: Path) -> Path:
|
|
70
|
+
"""Stream the task workspace archive to dest (tar.gz)."""
|
|
71
|
+
async with self._client.stream(
|
|
72
|
+
"GET", f"/api/runner/work/{item_id}/workspace", timeout=_IO_TIMEOUT
|
|
73
|
+
) as resp:
|
|
74
|
+
self._check_auth(resp)
|
|
75
|
+
resp.raise_for_status()
|
|
76
|
+
with dest.open("wb") as f:
|
|
77
|
+
async for chunk in resp.aiter_bytes():
|
|
78
|
+
f.write(chunk)
|
|
79
|
+
return dest
|
|
80
|
+
|
|
81
|
+
async def post_events(self, item_id: str, events: list[dict]) -> bool:
|
|
82
|
+
"""Forward an event batch; the reply carries the cancel signal."""
|
|
83
|
+
resp = await self._client.post(
|
|
84
|
+
f"/api/runner/work/{item_id}/events", json={"events": events}
|
|
85
|
+
)
|
|
86
|
+
self._check_auth(resp)
|
|
87
|
+
resp.raise_for_status()
|
|
88
|
+
try:
|
|
89
|
+
return bool(resp.json().get("cancelled"))
|
|
90
|
+
except ValueError:
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
async def post_result(self, item_id: str, result: dict,
|
|
94
|
+
tar_path: Path | None = None) -> None:
|
|
95
|
+
"""Upload the task outcome (+ the changed-files archive when any)."""
|
|
96
|
+
import json
|
|
97
|
+
|
|
98
|
+
data = {"result": json.dumps(result)}
|
|
99
|
+
if tar_path is not None:
|
|
100
|
+
with tar_path.open("rb") as f:
|
|
101
|
+
resp = await self._client.post(
|
|
102
|
+
f"/api/runner/work/{item_id}/result", data=data,
|
|
103
|
+
files={"files": ("files.tar.gz", f, "application/gzip")},
|
|
104
|
+
timeout=_IO_TIMEOUT,
|
|
105
|
+
)
|
|
106
|
+
else:
|
|
107
|
+
resp = await self._client.post(
|
|
108
|
+
f"/api/runner/work/{item_id}/result", data=data, timeout=_IO_TIMEOUT
|
|
109
|
+
)
|
|
110
|
+
self._check_auth(resp)
|
|
111
|
+
resp.raise_for_status()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Runner configuration: ~/.genesis/runner.toml, mode 0600.
|
|
2
|
+
|
|
3
|
+
The file holds the server URL, the runner token (a credential — hence the
|
|
4
|
+
restrictive mode and the minimal writer that never round-trips through a
|
|
5
|
+
general serializer), and this machine's runner name.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import tomllib
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
_FIELDS = ("server", "token", "name")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def config_dir() -> Path:
|
|
18
|
+
return Path(os.environ.get("GENESIS_RUNNER_HOME", str(Path.home() / ".genesis")))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def config_path() -> Path:
|
|
22
|
+
return config_dir() / "runner.toml"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def work_root() -> Path:
|
|
26
|
+
"""Where claimed task workspaces are materialized (and cleaned up)."""
|
|
27
|
+
root = config_dir() / "runner-work"
|
|
28
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
return root
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_config() -> dict:
|
|
33
|
+
path = config_path()
|
|
34
|
+
if not path.exists():
|
|
35
|
+
return {}
|
|
36
|
+
try:
|
|
37
|
+
data = tomllib.loads(path.read_text())
|
|
38
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
39
|
+
return {}
|
|
40
|
+
return {k: v for k, v in data.items() if k in _FIELDS and isinstance(v, str)}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _toml_str(value: str) -> str:
|
|
44
|
+
# Minimal TOML basic-string escaping: backslash and double quote.
|
|
45
|
+
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def save_config(server: str, token: str, name: str) -> Path:
|
|
49
|
+
path = config_path()
|
|
50
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
body = (
|
|
52
|
+
"# genesis runner credentials — created by `genesis runner connect`\n"
|
|
53
|
+
f"server = {_toml_str(server)}\n"
|
|
54
|
+
f"token = {_toml_str(token)}\n"
|
|
55
|
+
f"name = {_toml_str(name)}\n"
|
|
56
|
+
)
|
|
57
|
+
# Create 0600 BEFORE writing the secret (never a world-readable window).
|
|
58
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
59
|
+
with os.fdopen(fd, "w") as f:
|
|
60
|
+
f.write(body)
|
|
61
|
+
os.chmod(path, 0o600)
|
|
62
|
+
return path
|