cli-relay 0.1.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.
- cli_relay/__init__.py +39 -0
- cli_relay/__main__.py +5 -0
- cli_relay/adapters/__init__.py +38 -0
- cli_relay/adapters/antigravity.py +109 -0
- cli_relay/adapters/base.py +153 -0
- cli_relay/adapters/claude.py +169 -0
- cli_relay/adapters/codex.py +120 -0
- cli_relay/adapters/openai_compat.py +144 -0
- cli_relay/cli.py +238 -0
- cli_relay/config.py +131 -0
- cli_relay/errors.py +85 -0
- cli_relay/events.py +115 -0
- cli_relay/handoff.py +68 -0
- cli_relay/mcp_server.py +102 -0
- cli_relay/models.py +67 -0
- cli_relay/ndjson.py +41 -0
- cli_relay/proc.py +184 -0
- cli_relay/py.typed +0 -0
- cli_relay/quota.py +123 -0
- cli_relay/relay.py +137 -0
- cli_relay-0.1.0.dist-info/METADATA +227 -0
- cli_relay-0.1.0.dist-info/RECORD +25 -0
- cli_relay-0.1.0.dist-info/WHEEL +4 -0
- cli_relay-0.1.0.dist-info/entry_points.txt +2 -0
- cli_relay-0.1.0.dist-info/licenses/LICENSE +202 -0
cli_relay/events.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Normalized event stream shared by every provider adapter.
|
|
2
|
+
|
|
3
|
+
Every adapter run yields zero or more progress events and then exactly one terminal
|
|
4
|
+
event: :class:`Completed` or :class:`Failed`. The relay adds its own routing events
|
|
5
|
+
(:class:`Skipped`, :class:`Waiting`, :class:`Switched`).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import enum
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import asdict, dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FailureKind(enum.StrEnum):
|
|
17
|
+
RATE_LIMIT = "rate_limit"
|
|
18
|
+
AUTH = "auth"
|
|
19
|
+
NOT_INSTALLED = "not_installed"
|
|
20
|
+
TIMEOUT = "timeout"
|
|
21
|
+
CANCELLED = "cancelled"
|
|
22
|
+
ERROR = "error"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, kw_only=True)
|
|
26
|
+
class Event:
|
|
27
|
+
provider: str
|
|
28
|
+
at: float = field(default_factory=time.time)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def type(self) -> str:
|
|
32
|
+
name = type(self).__name__
|
|
33
|
+
return "".join("_" + c.lower() if c.isupper() else c for c in name).lstrip("_")
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def terminal(self) -> bool:
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> dict[str, Any]:
|
|
40
|
+
data = asdict(self)
|
|
41
|
+
for key, value in data.items():
|
|
42
|
+
if isinstance(value, enum.Enum):
|
|
43
|
+
data[key] = value.value
|
|
44
|
+
return {"type": self.type, **data}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, kw_only=True)
|
|
48
|
+
class Started(Event):
|
|
49
|
+
session_id: str | None = None
|
|
50
|
+
model: str | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, kw_only=True)
|
|
54
|
+
class TextDelta(Event):
|
|
55
|
+
text: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, kw_only=True)
|
|
59
|
+
class ToolActivity(Event):
|
|
60
|
+
tool: str
|
|
61
|
+
detail: str = ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, kw_only=True)
|
|
65
|
+
class QuotaInfo(Event):
|
|
66
|
+
"""A usage window reported by the provider (e.g. Claude's five_hour / seven_day)."""
|
|
67
|
+
|
|
68
|
+
window: str
|
|
69
|
+
status: str
|
|
70
|
+
utilization: float | None = None
|
|
71
|
+
resets_at: float | None = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, kw_only=True)
|
|
75
|
+
class Completed(Event):
|
|
76
|
+
text: str
|
|
77
|
+
session_id: str | None = None
|
|
78
|
+
usage: dict[str, Any] = field(default_factory=dict)
|
|
79
|
+
cost_usd: float | None = None
|
|
80
|
+
duration_s: float | None = None
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def terminal(self) -> bool:
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, kw_only=True)
|
|
88
|
+
class Failed(Event):
|
|
89
|
+
kind: FailureKind
|
|
90
|
+
message: str = ""
|
|
91
|
+
resets_at: float | None = None
|
|
92
|
+
session_id: str | None = None
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def terminal(self) -> bool:
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True, kw_only=True)
|
|
100
|
+
class Skipped(Event):
|
|
101
|
+
reason: str
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True, kw_only=True)
|
|
105
|
+
class Waiting(Event):
|
|
106
|
+
until: float
|
|
107
|
+
reason: str = ""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True, kw_only=True)
|
|
111
|
+
class Switched(Event):
|
|
112
|
+
"""Emitted by the relay; ``provider`` is the one being left."""
|
|
113
|
+
|
|
114
|
+
to_provider: str
|
|
115
|
+
reason: str
|
cli_relay/handoff.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Carry a half-finished task over to the next provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from cli_relay.events import Event, Failed, TextDelta, ToolActivity
|
|
10
|
+
|
|
11
|
+
HANDOFF_FILE = "HANDOFF.md"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class HandoffLog:
|
|
16
|
+
"""Collects what each provider did, to brief the next one."""
|
|
17
|
+
|
|
18
|
+
original_prompt: str
|
|
19
|
+
max_tools: int = 40
|
|
20
|
+
max_text_chars: int = 6000
|
|
21
|
+
attempts: list[tuple[str, str]] = field(default_factory=list) # (provider, reason)
|
|
22
|
+
tools: deque[str] = field(default_factory=deque)
|
|
23
|
+
text: str = ""
|
|
24
|
+
|
|
25
|
+
def observe(self, event: Event) -> None:
|
|
26
|
+
if isinstance(event, ToolActivity):
|
|
27
|
+
self.tools.append(f"[{event.provider}] {event.tool} {event.detail}".rstrip())
|
|
28
|
+
while len(self.tools) > self.max_tools:
|
|
29
|
+
self.tools.popleft()
|
|
30
|
+
elif isinstance(event, TextDelta):
|
|
31
|
+
self.text = (self.text + event.text)[-self.max_text_chars :]
|
|
32
|
+
elif isinstance(event, Failed):
|
|
33
|
+
self.attempts.append((event.provider, f"{event.kind.value}: {event.message[:200]}"))
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def has_progress(self) -> bool:
|
|
37
|
+
return bool(self.tools or self.text.strip())
|
|
38
|
+
|
|
39
|
+
def render(self) -> str:
|
|
40
|
+
lines = ["# Handoff", "", "## Original task", "", self.original_prompt.strip(), ""]
|
|
41
|
+
if self.attempts:
|
|
42
|
+
lines += ["## Previous attempts", ""]
|
|
43
|
+
lines += [f"- {p} stopped: {r}" for p, r in self.attempts]
|
|
44
|
+
lines.append("")
|
|
45
|
+
if self.tools:
|
|
46
|
+
lines += ["## Actions already taken (most recent last)", ""]
|
|
47
|
+
lines += [f"- {t}" for t in self.tools]
|
|
48
|
+
lines.append("")
|
|
49
|
+
if self.text.strip():
|
|
50
|
+
lines += ["## Last output of the previous agent", "", self.text.strip(), ""]
|
|
51
|
+
return "\n".join(lines)
|
|
52
|
+
|
|
53
|
+
def continuation_prompt(self) -> str:
|
|
54
|
+
if not self.has_progress:
|
|
55
|
+
return self.original_prompt
|
|
56
|
+
return (
|
|
57
|
+
"You are taking over a task from another coding agent that had to stop "
|
|
58
|
+
"(for example because it hit a usage limit). Its handoff notes are below.\n"
|
|
59
|
+
"The previous agent may already have changed files: before doing anything else, "
|
|
60
|
+
"inspect the current state of the working directory (for example `git status` "
|
|
61
|
+
"and `git diff`). Do not redo work that is already done; finish the task.\n\n"
|
|
62
|
+
+ self.render()
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def write(self, directory: Path) -> Path:
|
|
66
|
+
path = directory / HANDOFF_FILE
|
|
67
|
+
path.write_text(self.render(), encoding="utf-8")
|
|
68
|
+
return path
|
cli_relay/mcp_server.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Expose the relay as an MCP server (stdio), so any MCP client - Claude Code, Codex,
|
|
2
|
+
Antigravity, a voice assistant - can delegate a task and get fallback for free.
|
|
3
|
+
|
|
4
|
+
Requires the optional dependency: ``pip install "cli-relay[mcp]"``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# No ``from __future__ import annotations`` here: FastMCP inspects the tool signatures at
|
|
8
|
+
# runtime and must see the real ``Context`` class, which is imported lazily below.
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from cli_relay.config import Config
|
|
15
|
+
from cli_relay.events import Completed, Failed, Skipped, Started, Switched
|
|
16
|
+
from cli_relay.models import Permission, Task
|
|
17
|
+
from cli_relay.relay import Relay
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_server(config: Config) -> Any:
|
|
21
|
+
try:
|
|
22
|
+
from mcp.server.fastmcp import Context, FastMCP
|
|
23
|
+
except ImportError as exc: # pragma: no cover - depends on optional extra
|
|
24
|
+
raise SystemExit(
|
|
25
|
+
"cli-relay mcp needs the optional dependency: pip install 'cli-relay[mcp]'"
|
|
26
|
+
) from exc
|
|
27
|
+
|
|
28
|
+
server = FastMCP("cli-relay")
|
|
29
|
+
|
|
30
|
+
@server.tool()
|
|
31
|
+
async def relay_run(
|
|
32
|
+
prompt: str,
|
|
33
|
+
cwd: str | None = None,
|
|
34
|
+
providers: list[str] | None = None,
|
|
35
|
+
permission: str = "read_only",
|
|
36
|
+
ctx: Context[Any, Any, Any] | None = None,
|
|
37
|
+
) -> dict[str, Any]:
|
|
38
|
+
"""Run a coding task on the first available agent CLI (Claude Code, Antigravity,
|
|
39
|
+
Codex, API...), switching to the next one if a provider hits its usage limit.
|
|
40
|
+
|
|
41
|
+
permission: read_only (default), edit, or full. The server caps it at the
|
|
42
|
+
configured ``[mcp] max_permission`` (read_only unless the user raised it).
|
|
43
|
+
Returns which provider finished, its final answer and any switches that happened.
|
|
44
|
+
"""
|
|
45
|
+
granted = Permission(permission).capped_by(config.mcp_max_permission)
|
|
46
|
+
relay = Relay(config.adapters(providers), config.quota(), config.policy)
|
|
47
|
+
task = Task(prompt=prompt, cwd=Path(cwd) if cwd else None, permission=granted)
|
|
48
|
+
switches: list[str] = []
|
|
49
|
+
skipped: list[str] = []
|
|
50
|
+
final: Completed | Failed | None = None
|
|
51
|
+
async for event in relay.run(task):
|
|
52
|
+
if isinstance(event, Switched):
|
|
53
|
+
switches.append(f"{event.provider} -> {event.to_provider} ({event.reason})")
|
|
54
|
+
elif isinstance(event, Skipped):
|
|
55
|
+
skipped.append(f"{event.provider}: {event.reason}")
|
|
56
|
+
elif isinstance(event, Started) and ctx is not None:
|
|
57
|
+
with contextlib.suppress(Exception): # no request context when called directly
|
|
58
|
+
await ctx.info(f"{event.provider} started")
|
|
59
|
+
elif isinstance(event, Completed | Failed):
|
|
60
|
+
final = event
|
|
61
|
+
if isinstance(final, Completed):
|
|
62
|
+
return {
|
|
63
|
+
"ok": True,
|
|
64
|
+
"provider": final.provider,
|
|
65
|
+
"text": final.text,
|
|
66
|
+
"session_id": final.session_id,
|
|
67
|
+
"permission": granted.value,
|
|
68
|
+
"switches": switches,
|
|
69
|
+
"skipped": skipped,
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
"ok": False,
|
|
73
|
+
"error": final.message if final else "no result",
|
|
74
|
+
"kind": final.kind.value if isinstance(final, Failed) else None,
|
|
75
|
+
"switches": switches,
|
|
76
|
+
"skipped": skipped,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@server.tool()
|
|
80
|
+
def relay_status() -> dict[str, Any]:
|
|
81
|
+
"""Show which agent providers are installed, and which are rate-limited and until when."""
|
|
82
|
+
quota = config.quota()
|
|
83
|
+
out = []
|
|
84
|
+
for adapter in config.adapters():
|
|
85
|
+
ok, reason = adapter.available()
|
|
86
|
+
block = quota.blocked(adapter.name)
|
|
87
|
+
out.append(
|
|
88
|
+
{
|
|
89
|
+
"provider": adapter.name,
|
|
90
|
+
"available": ok,
|
|
91
|
+
"reason": reason,
|
|
92
|
+
"blocked_until": block.until if block else None,
|
|
93
|
+
"blocked_reason": block.reason if block else None,
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
return {"order": config.order, "providers": out}
|
|
97
|
+
|
|
98
|
+
return server
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def serve(config: Config) -> None:
|
|
102
|
+
build_server(config).run()
|
cli_relay/models.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Task and provider configuration models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
from dataclasses import dataclass, field, replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Permission(enum.StrEnum):
|
|
11
|
+
"""What a worker may do, mapped to each CLI's own permission flags.
|
|
12
|
+
|
|
13
|
+
* ``read_only`` - read files and answer; anything that would write or run commands
|
|
14
|
+
is denied without prompting.
|
|
15
|
+
* ``edit`` - may edit files inside the working directory; no shell commands unless
|
|
16
|
+
the provider config allows them explicitly.
|
|
17
|
+
* ``full`` - the provider's own automatic mode (Claude ``auto``, Antigravity ``auto``,
|
|
18
|
+
Codex ``workspace-write`` + ``--approve-for-me``). Never a "skip all checks" mode.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
READ_ONLY = "read_only"
|
|
22
|
+
EDIT = "edit"
|
|
23
|
+
FULL = "full"
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def rank(self) -> int:
|
|
27
|
+
return ("read_only", "edit", "full").index(self.value)
|
|
28
|
+
|
|
29
|
+
def capped_by(self, cap: Permission | None) -> Permission:
|
|
30
|
+
if cap is None or self.rank <= cap.rank:
|
|
31
|
+
return self
|
|
32
|
+
return cap
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Task:
|
|
37
|
+
prompt: str
|
|
38
|
+
cwd: Path | None = None
|
|
39
|
+
permission: Permission = Permission.READ_ONLY
|
|
40
|
+
#: Extra instructions appended to the provider's system prompt.
|
|
41
|
+
system_prompt: str | None = None
|
|
42
|
+
#: Session id to resume, per provider name.
|
|
43
|
+
resume: dict[str, str] = field(default_factory=dict)
|
|
44
|
+
#: Hard wall-clock limit for one provider attempt, in seconds.
|
|
45
|
+
timeout_s: float | None = None
|
|
46
|
+
|
|
47
|
+
def with_prompt(self, prompt: str) -> Task:
|
|
48
|
+
return replace(self, prompt=prompt)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class ProviderSettings:
|
|
53
|
+
"""Configuration of one provider entry (``[providers.<name>]`` in the config file)."""
|
|
54
|
+
|
|
55
|
+
name: str
|
|
56
|
+
type: str
|
|
57
|
+
#: Full launcher command, e.g. ``["claude"]`` or ``["wsl", "-e", "claude"]``.
|
|
58
|
+
#: ``None`` means the adapter's default binary looked up on ``PATH``.
|
|
59
|
+
command: list[str] | None = None
|
|
60
|
+
model: str | None = None
|
|
61
|
+
#: Upper bound: a task never runs with more than this on this provider.
|
|
62
|
+
permission: Permission | None = None
|
|
63
|
+
extra_args: list[str] = field(default_factory=list)
|
|
64
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
65
|
+
# OpenAI-compatible HTTP providers only:
|
|
66
|
+
base_url: str | None = None
|
|
67
|
+
api_key_env: str | None = None
|
cli_relay/ndjson.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Tolerant NDJSON parsing.
|
|
2
|
+
|
|
3
|
+
CLI event streams are usually one JSON object per line, but in practice a line can be
|
|
4
|
+
corrupted (e.g. an event written into the middle of another one) or contain stray log
|
|
5
|
+
text. We never want one bad line to kill a long agent run, so we recover every object
|
|
6
|
+
we can and drop the rest.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
_decoder = json.JSONDecoder()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_line(line: str) -> list[dict[str, Any]]:
|
|
18
|
+
"""Return every JSON object that can be recovered from ``line``."""
|
|
19
|
+
line = line.strip()
|
|
20
|
+
if not line:
|
|
21
|
+
return []
|
|
22
|
+
try:
|
|
23
|
+
obj = json.loads(line)
|
|
24
|
+
except ValueError:
|
|
25
|
+
return _salvage(line)
|
|
26
|
+
return [obj] if isinstance(obj, dict) else []
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _salvage(line: str) -> list[dict[str, Any]]:
|
|
30
|
+
found: list[dict[str, Any]] = []
|
|
31
|
+
pos = line.find("{")
|
|
32
|
+
while pos != -1:
|
|
33
|
+
try:
|
|
34
|
+
obj, end = _decoder.raw_decode(line, pos)
|
|
35
|
+
except ValueError:
|
|
36
|
+
pos = line.find("{", pos + 1)
|
|
37
|
+
continue
|
|
38
|
+
if isinstance(obj, dict):
|
|
39
|
+
found.append(obj)
|
|
40
|
+
pos = line.find("{", end)
|
|
41
|
+
return found
|
cli_relay/proc.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Subprocess plumbing: launch a CLI, stream stdout lines, stop it cleanly.
|
|
2
|
+
|
|
3
|
+
Security notes
|
|
4
|
+
--------------
|
|
5
|
+
* Commands are always launched as an argument vector, never through a shell.
|
|
6
|
+
* On Windows, ``.cmd``/``.bat`` launchers (npm shims such as ``claude.cmd``) are run by
|
|
7
|
+
``cmd.exe``, which re-parses the arguments. Adapters therefore pass user text through
|
|
8
|
+
stdin or temp files, and :func:`check_cmd_shim_args` refuses arguments containing
|
|
9
|
+
``cmd.exe`` metacharacters for such launchers.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import contextlib
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import signal
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from collections.abc import AsyncIterator, Sequence
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
_STREAM_LIMIT = 16 * 1024 * 1024
|
|
25
|
+
_STDERR_TAIL = 64 * 1024
|
|
26
|
+
_CMD_META = set('&|<>^%!"\r\n')
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class UnsafeArgumentError(ValueError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resolve(command: Sequence[str]) -> list[str] | None:
|
|
34
|
+
"""Resolve ``command[0]`` on ``PATH``; ``None`` if it is not installed."""
|
|
35
|
+
if not command:
|
|
36
|
+
return None
|
|
37
|
+
exe = shutil.which(command[0])
|
|
38
|
+
if exe is None:
|
|
39
|
+
return None
|
|
40
|
+
return [exe, *command[1:]]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_cmd_shim(exe: str) -> bool:
|
|
44
|
+
return sys.platform == "win32" and exe.lower().endswith((".cmd", ".bat"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def check_cmd_shim_args(argv: Sequence[str]) -> None:
|
|
48
|
+
if not argv or not is_cmd_shim(argv[0]):
|
|
49
|
+
return
|
|
50
|
+
for arg in argv[1:]:
|
|
51
|
+
bad = _CMD_META.intersection(arg)
|
|
52
|
+
if bad:
|
|
53
|
+
raise UnsafeArgumentError(
|
|
54
|
+
f"refusing to pass {arg!r} to {Path(argv[0]).name}: cmd.exe would interpret "
|
|
55
|
+
f"{''.join(sorted(bad))!r}. Pass it via stdin/a file, or point the provider "
|
|
56
|
+
f"'command' at the real executable instead of the .cmd shim."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ProcessRun:
|
|
61
|
+
"""One running CLI process. Iterate :meth:`lines`; afterwards read ``returncode``."""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
argv: Sequence[str],
|
|
66
|
+
*,
|
|
67
|
+
cwd: Path | None = None,
|
|
68
|
+
stdin_data: bytes | None = None,
|
|
69
|
+
env: dict[str, str] | None = None,
|
|
70
|
+
timeout_s: float | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
check_cmd_shim_args(argv)
|
|
73
|
+
self.argv = list(argv)
|
|
74
|
+
self.cwd = cwd
|
|
75
|
+
self.stdin_data = stdin_data
|
|
76
|
+
self.env = {**os.environ, **env} if env else None
|
|
77
|
+
self.timeout_s = timeout_s
|
|
78
|
+
self.returncode: int | None = None
|
|
79
|
+
self.timed_out = False
|
|
80
|
+
self._stderr = bytearray()
|
|
81
|
+
self._proc: asyncio.subprocess.Process | None = None
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def stderr_tail(self) -> str:
|
|
85
|
+
return self._stderr.decode("utf-8", errors="replace")
|
|
86
|
+
|
|
87
|
+
async def lines(self) -> AsyncIterator[str]:
|
|
88
|
+
kwargs: dict[str, object] = {}
|
|
89
|
+
if sys.platform == "win32":
|
|
90
|
+
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
91
|
+
else:
|
|
92
|
+
kwargs["start_new_session"] = True
|
|
93
|
+
proc = await asyncio.create_subprocess_exec(
|
|
94
|
+
*self.argv,
|
|
95
|
+
cwd=str(self.cwd) if self.cwd else None,
|
|
96
|
+
env=self.env,
|
|
97
|
+
stdin=asyncio.subprocess.PIPE if self.stdin_data is not None else subprocess.DEVNULL,
|
|
98
|
+
stdout=asyncio.subprocess.PIPE,
|
|
99
|
+
stderr=asyncio.subprocess.PIPE,
|
|
100
|
+
limit=_STREAM_LIMIT,
|
|
101
|
+
**kwargs, # type: ignore[arg-type]
|
|
102
|
+
)
|
|
103
|
+
self._proc = proc
|
|
104
|
+
stderr_task = asyncio.create_task(self._drain_stderr(proc))
|
|
105
|
+
# Feed stdin concurrently with reading stdout, so a large prompt can never deadlock
|
|
106
|
+
# against a child that is already writing output.
|
|
107
|
+
stdin_task = asyncio.create_task(self._feed_stdin(proc))
|
|
108
|
+
loop = asyncio.get_running_loop()
|
|
109
|
+
deadline = loop.time() + self.timeout_s if self.timeout_s else None
|
|
110
|
+
assert proc.stdout is not None
|
|
111
|
+
try:
|
|
112
|
+
while True:
|
|
113
|
+
remaining = None if deadline is None else max(0.0, deadline - loop.time())
|
|
114
|
+
try:
|
|
115
|
+
raw = await asyncio.wait_for(proc.stdout.readline(), remaining)
|
|
116
|
+
except TimeoutError:
|
|
117
|
+
self.timed_out = True
|
|
118
|
+
await self.stop()
|
|
119
|
+
break
|
|
120
|
+
except ValueError:
|
|
121
|
+
# A single line longer than the stream limit: asyncio drops it from the
|
|
122
|
+
# buffer; skip it rather than abort the whole run.
|
|
123
|
+
continue
|
|
124
|
+
if not raw:
|
|
125
|
+
break
|
|
126
|
+
yield raw.decode("utf-8", errors="replace")
|
|
127
|
+
self.returncode = await proc.wait()
|
|
128
|
+
except BaseException:
|
|
129
|
+
await self.stop()
|
|
130
|
+
raise
|
|
131
|
+
finally:
|
|
132
|
+
if not stdin_task.done():
|
|
133
|
+
stdin_task.cancel()
|
|
134
|
+
await asyncio.gather(stdin_task, stderr_task, return_exceptions=True)
|
|
135
|
+
|
|
136
|
+
async def _feed_stdin(self, proc: asyncio.subprocess.Process) -> None:
|
|
137
|
+
if self.stdin_data is None or proc.stdin is None:
|
|
138
|
+
return
|
|
139
|
+
with contextlib.suppress(BrokenPipeError, ConnectionResetError):
|
|
140
|
+
proc.stdin.write(self.stdin_data)
|
|
141
|
+
await proc.stdin.drain()
|
|
142
|
+
proc.stdin.close()
|
|
143
|
+
|
|
144
|
+
async def _drain_stderr(self, proc: asyncio.subprocess.Process) -> None:
|
|
145
|
+
assert proc.stderr is not None
|
|
146
|
+
while chunk := await proc.stderr.read(8192):
|
|
147
|
+
self._stderr.extend(chunk)
|
|
148
|
+
if len(self._stderr) > _STDERR_TAIL:
|
|
149
|
+
del self._stderr[: len(self._stderr) - _STDERR_TAIL]
|
|
150
|
+
|
|
151
|
+
async def stop(self, grace_s: float = 5.0) -> None:
|
|
152
|
+
"""Interrupt politely (SIGINT / CTRL_BREAK), then kill the whole process tree."""
|
|
153
|
+
proc = self._proc
|
|
154
|
+
if proc is None or proc.returncode is not None:
|
|
155
|
+
return
|
|
156
|
+
with contextlib.suppress(ProcessLookupError, OSError):
|
|
157
|
+
if sys.platform == "win32":
|
|
158
|
+
proc.send_signal(signal.CTRL_BREAK_EVENT)
|
|
159
|
+
else:
|
|
160
|
+
os.killpg(proc.pid, signal.SIGINT)
|
|
161
|
+
try:
|
|
162
|
+
await asyncio.wait_for(proc.wait(), grace_s)
|
|
163
|
+
except TimeoutError:
|
|
164
|
+
await _kill_tree(proc)
|
|
165
|
+
self.returncode = proc.returncode
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def _kill_tree(proc: asyncio.subprocess.Process) -> None:
|
|
169
|
+
with contextlib.suppress(ProcessLookupError, OSError):
|
|
170
|
+
if sys.platform == "win32":
|
|
171
|
+
killer = await asyncio.create_subprocess_exec(
|
|
172
|
+
"taskkill",
|
|
173
|
+
"/T",
|
|
174
|
+
"/F",
|
|
175
|
+
"/PID",
|
|
176
|
+
str(proc.pid),
|
|
177
|
+
stdout=subprocess.DEVNULL,
|
|
178
|
+
stderr=subprocess.DEVNULL,
|
|
179
|
+
)
|
|
180
|
+
await killer.wait()
|
|
181
|
+
else:
|
|
182
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
183
|
+
with contextlib.suppress(Exception):
|
|
184
|
+
await asyncio.wait_for(proc.wait(), 5)
|
cli_relay/py.typed
ADDED
|
File without changes
|