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/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
@@ -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