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/quota.py ADDED
@@ -0,0 +1,123 @@
1
+ """Remembers which providers are rate-limited and until when, across runs.
2
+
3
+ State is a small JSON file (see :func:`default_state_path`). Writes are atomic; concurrent
4
+ cli-relay processes may overwrite each other's newest observation, which is acceptable
5
+ for a single-user tool.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import sys
13
+ import tempfile
14
+ import time
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from cli_relay.events import Event, Failed, FailureKind, QuotaInfo
20
+
21
+
22
+ def default_state_path() -> Path:
23
+ if env := os.environ.get("CLI_RELAY_STATE"):
24
+ return Path(env)
25
+ if sys.platform == "win32":
26
+ base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
27
+ else:
28
+ base = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state")
29
+ return base / "cli-relay" / "state.json"
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Block:
34
+ until: float
35
+ reason: str
36
+
37
+
38
+ class QuotaTracker:
39
+ def __init__(
40
+ self,
41
+ path: Path | None = None,
42
+ *,
43
+ default_cooldown_s: float = 1800,
44
+ skip_above_utilization: float | None = 0.98,
45
+ ) -> None:
46
+ self.path = path
47
+ self.default_cooldown_s = default_cooldown_s
48
+ self.skip_above_utilization = skip_above_utilization
49
+ self._data: dict[str, dict[str, Any]] = self._load()
50
+
51
+ # -- persistence -------------------------------------------------------------------
52
+ def _load(self) -> dict[str, dict[str, Any]]:
53
+ if not self.path or not self.path.exists():
54
+ return {}
55
+ try:
56
+ data = json.loads(self.path.read_text(encoding="utf-8"))
57
+ except (OSError, ValueError):
58
+ return {}
59
+ return data if isinstance(data, dict) else {}
60
+
61
+ def _save(self) -> None:
62
+ if not self.path:
63
+ return
64
+ self.path.parent.mkdir(parents=True, exist_ok=True)
65
+ fd, tmp = tempfile.mkstemp(dir=self.path.parent, prefix=".state-", suffix=".json")
66
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
67
+ json.dump(self._data, fh, indent=2, sort_keys=True)
68
+ os.replace(tmp, self.path)
69
+
70
+ def _entry(self, provider: str) -> dict[str, Any]:
71
+ return self._data.setdefault(provider, {})
72
+
73
+ # -- observation -------------------------------------------------------------------
74
+ def observe(self, event: Event) -> None:
75
+ if isinstance(event, QuotaInfo):
76
+ windows = self._entry(event.provider).setdefault("windows", {})
77
+ windows[event.window] = {
78
+ "status": event.status,
79
+ "utilization": event.utilization,
80
+ "resets_at": event.resets_at,
81
+ "seen_at": event.at,
82
+ }
83
+ self._save()
84
+ elif isinstance(event, Failed) and event.kind is FailureKind.RATE_LIMIT:
85
+ until = event.resets_at or (event.at + self.default_cooldown_s)
86
+ self.block(event.provider, until, event.message[:300] or "rate limited")
87
+
88
+ def block(self, provider: str, until: float, reason: str) -> None:
89
+ self._entry(provider)["blocked"] = {"until": until, "reason": reason}
90
+ self._save()
91
+
92
+ def clear(self, provider: str | None = None) -> None:
93
+ if provider is None:
94
+ self._data.clear()
95
+ else:
96
+ self._data.pop(provider, None)
97
+ self._save()
98
+
99
+ # -- queries -----------------------------------------------------------------------
100
+ def blocked(self, provider: str, now: float | None = None) -> Block | None:
101
+ now = time.time() if now is None else now
102
+ entry = self._data.get(provider, {})
103
+ blocked = entry.get("blocked")
104
+ if blocked and float(blocked.get("until", 0)) > now:
105
+ return Block(float(blocked["until"]), str(blocked.get("reason", "")))
106
+ limit = self.skip_above_utilization
107
+ for name, window in (entry.get("windows") or {}).items():
108
+ resets = window.get("resets_at")
109
+ if not resets or float(resets) <= now:
110
+ continue
111
+ util = window.get("utilization")
112
+ exhausted = window.get("status") not in (None, "allowed", "allowed_warning")
113
+ if exhausted or (limit is not None and util is not None and float(util) >= limit):
114
+ return Block(
115
+ float(resets),
116
+ f"{name} window at {util:.0%}"
117
+ if util is not None
118
+ else f"{name} window {window.get('status')}",
119
+ )
120
+ return None
121
+
122
+ def snapshot(self) -> dict[str, dict[str, Any]]:
123
+ return json.loads(json.dumps(self._data)) # type: ignore[no-any-return]
cli_relay/relay.py ADDED
@@ -0,0 +1,137 @@
1
+ """The relay: try providers in order, switch on limits, brief the next one."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from collections.abc import AsyncIterator, Sequence
8
+ from dataclasses import dataclass, field
9
+
10
+ from cli_relay.adapters.base import Adapter
11
+ from cli_relay.events import (
12
+ Completed,
13
+ Event,
14
+ Failed,
15
+ FailureKind,
16
+ Skipped,
17
+ Switched,
18
+ Waiting,
19
+ )
20
+ from cli_relay.handoff import HandoffLog
21
+ from cli_relay.models import Task
22
+ from cli_relay.quota import QuotaTracker
23
+
24
+ RELAY = "relay"
25
+
26
+
27
+ @dataclass
28
+ class RelayPolicy:
29
+ #: Failure kinds that make the relay move on to the next provider.
30
+ switch_on: frozenset[FailureKind] = field(
31
+ default_factory=lambda: frozenset(
32
+ {
33
+ FailureKind.RATE_LIMIT,
34
+ FailureKind.AUTH,
35
+ FailureKind.NOT_INSTALLED,
36
+ FailureKind.ERROR,
37
+ }
38
+ )
39
+ )
40
+ #: If a blocked provider resets within this many seconds, wait for it instead of
41
+ #: skipping it. ``0`` disables waiting.
42
+ wait_if_reset_within_s: float = 0
43
+ #: Write HANDOFF.md into the task's working directory when switching mid-task.
44
+ write_handoff_file: bool = False
45
+
46
+
47
+ class Relay:
48
+ def __init__(
49
+ self,
50
+ adapters: Sequence[Adapter],
51
+ quota: QuotaTracker | None = None,
52
+ policy: RelayPolicy | None = None,
53
+ ) -> None:
54
+ if not adapters:
55
+ raise ValueError("at least one provider is required")
56
+ self.adapters = list(adapters)
57
+ self.quota = quota or QuotaTracker()
58
+ self.policy = policy or RelayPolicy()
59
+
60
+ async def run(self, task: Task) -> AsyncIterator[Event]:
61
+ """Stream events from the first provider that finishes the task.
62
+
63
+ The stream ends with the provider's :class:`Completed` event, or with a
64
+ :class:`Failed` event from provider ``"relay"`` when every provider failed.
65
+ """
66
+ log = HandoffLog(task.prompt)
67
+ previous: str | None = None
68
+ last_failure: Failed | None = None
69
+ for adapter in self.adapters:
70
+ ok, reason = adapter.available()
71
+ if not ok:
72
+ yield Skipped(provider=adapter.name, reason=reason)
73
+ continue
74
+ block = self.quota.blocked(adapter.name)
75
+ if block:
76
+ wait = block.until - time.time()
77
+ if 0 < wait <= self.policy.wait_if_reset_within_s:
78
+ yield Waiting(provider=adapter.name, until=block.until, reason=block.reason)
79
+ await asyncio.sleep(wait)
80
+ else:
81
+ yield Skipped(
82
+ provider=adapter.name,
83
+ reason=f"limited until {_clock(block.until)}: {block.reason}",
84
+ )
85
+ continue
86
+ if previous is not None:
87
+ yield Switched(
88
+ provider=previous,
89
+ to_provider=adapter.name,
90
+ reason=last_failure.kind.value if last_failure else "",
91
+ )
92
+ if self.policy.write_handoff_file and task.cwd and log.has_progress:
93
+ log.write(task.cwd)
94
+ attempt = task if previous is None else task.with_prompt(log.continuation_prompt())
95
+ result: Event | None = None
96
+ async for event in adapter.run(attempt):
97
+ self.quota.observe(event)
98
+ log.observe(event)
99
+ yield event
100
+ if event.terminal:
101
+ result = event
102
+ if isinstance(result, Completed):
103
+ return
104
+ last_failure = (
105
+ result
106
+ if isinstance(result, Failed)
107
+ else Failed(
108
+ provider=adapter.name, kind=FailureKind.ERROR, message="no terminal event"
109
+ )
110
+ )
111
+ if last_failure.kind not in self.policy.switch_on:
112
+ yield Failed(
113
+ provider=RELAY,
114
+ kind=last_failure.kind,
115
+ message=f"{adapter.name}: {last_failure.message}",
116
+ )
117
+ return
118
+ previous = adapter.name
119
+ yield Failed(
120
+ provider=RELAY,
121
+ kind=last_failure.kind if last_failure else FailureKind.NOT_INSTALLED,
122
+ message="no provider could complete the task"
123
+ + (
124
+ f"; last error from {last_failure.provider}: {last_failure.message}"
125
+ if last_failure
126
+ else "; none was available"
127
+ ),
128
+ )
129
+
130
+ async def run_to_end(self, task: Task) -> tuple[Event, list[Event]]:
131
+ """Convenience wrapper: return ``(terminal_event, all_events)``."""
132
+ events = [event async for event in self.run(task)]
133
+ return events[-1], events
134
+
135
+
136
+ def _clock(epoch: float) -> str:
137
+ return time.strftime("%Y-%m-%d %H:%M", time.localtime(epoch))
@@ -0,0 +1,227 @@
1
+ Metadata-Version: 2.5
2
+ Name: cli-relay
3
+ Version: 0.1.0
4
+ Summary: Run coding-agent CLIs (Claude Code, Antigravity, Codex, any OpenAI-compatible API) as one worker with automatic fallback when a subscription hits its limit.
5
+ Project-URL: Homepage, https://github.com/mustikon/cli-relay
6
+ Project-URL: Issues, https://github.com/mustikon/cli-relay/issues
7
+ Project-URL: Changelog, https://github.com/mustikon/cli-relay/blob/main/CHANGELOG.md
8
+ Author: mustikon
9
+ License-Expression: Apache-2.0
10
+ License-File: LICENSE
11
+ Keywords: agents,antigravity,claude-code,codex,fallback,gemini,llm,mcp,rate-limit
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.11
23
+ Provides-Extra: mcp
24
+ Requires-Dist: mcp<2,>=1.10; extra == 'mcp'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # cli-relay
28
+
29
+ [![CI](https://github.com/mustikon/cli-relay/actions/workflows/ci.yml/badge.svg)](https://github.com/mustikon/cli-relay/actions/workflows/ci.yml)
30
+ [![PyPI](https://img.shields.io/pypi/v/cli-relay.svg)](https://pypi.org/project/cli-relay/)
31
+ [![Python](https://img.shields.io/pypi/pyversions/cli-relay.svg)](https://pypi.org/project/cli-relay/)
32
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
33
+
34
+ **One worker interface over several coding-agent CLIs, with automatic fallback when a
35
+ subscription hits its limit.**
36
+
37
+ [Русская версия](README.ru.md)
38
+
39
+ You pay for Claude, ChatGPT and Google subscriptions, and each comes with its own agent CLI
40
+ and its own usage window. `cli-relay` runs a task on the first provider that can take it.
41
+ If that provider runs out of quota mid-task, the relay hands the work to the next one with
42
+ a briefing on what was already done.
43
+
44
+ ```
45
+ cli-relay run "fix the failing test in tests/test_api.py" --cwd ~/code/app --permission edit
46
+
47
+ [cli-relay] claude started (claude-opus-5-5)
48
+ ...
49
+ [cli-relay] claude failed [rate_limit]: You've hit your limit · resets 3pm
50
+ [cli-relay] switching claude -> antigravity (rate_limit)
51
+ [cli-relay] antigravity started
52
+ ...
53
+ [cli-relay] done by antigravity
54
+ ```
55
+
56
+ ## Why
57
+
58
+ * **The official CLIs, unmodified.** `cli-relay` launches `claude`, `agy` and `codex`
59
+ exactly as you would, under your own login. It never extracts, proxies or reuses OAuth
60
+ tokens. You keep each tool's own permissions, hooks, `CLAUDE.md` / `AGENTS.md`, skills
61
+ and MCP servers.
62
+ * **Limit-aware.** Claude Code reports its five-hour and weekly windows in its event
63
+ stream. `cli-relay` records them and skips a provider *before* it fails when a window is
64
+ nearly used up. A provider that failed on a limit is skipped until its reset time.
65
+ * **Handoff, not restart.** When it switches providers mid-task, the next agent gets the
66
+ original task, the actions already taken and the last output. It is also told to check
67
+ `git status` / `git diff` before continuing.
68
+ * **One event stream.** Every provider is normalised to the same events (`started`,
69
+ `text_delta`, `tool_activity`, `quota_info`, `completed`, `failed`, plus the relay's
70
+ `skipped` / `waiting` / `switched`). Use `--format ndjson` to drive it from another
71
+ program, such as a voice assistant.
72
+ * **MCP server.** `cli-relay mcp` exposes `relay_run` and `relay_status`, so any MCP client
73
+ can delegate work and get fallback for free.
74
+ * **Small and dependency-free.** The core uses only the Python standard library. That
75
+ matters for a tool that runs next to all your credentials.
76
+
77
+ ## Providers
78
+
79
+ | Type | CLI / endpoint | Status | Session resume | Quota telemetry |
80
+ |---|---|---|---|---|
81
+ | `claude` | Claude Code `claude -p --output-format stream-json` | verified live | `--resume` | five-hour / weekly windows |
82
+ | `antigravity` | Google Antigravity `agy -p --output-format stream-json` | verified live | `--conversation` | from error text |
83
+ | `codex` | OpenAI Codex `codex exec --json` (also finds the copy bundled with the Codex desktop app on Windows) | command layout and limit path verified live on 0.167; success path from docs | `exec resume` | from error text, incl. "try again at 11:05 AM" |
84
+ | `openai` | any OpenAI-compatible `/chat/completions` (OpenRouter, vLLM, Ollama, LM Studio…) | tested with a local server | — | HTTP 429 + `Retry-After` |
85
+
86
+ The `openai` type is a **text-only** fallback: it has no tools and cannot edit files.
87
+
88
+ ## Install
89
+
90
+ ```bash
91
+ pipx install cli-relay # or: uv tool install cli-relay
92
+ pipx install "cli-relay[mcp]" # with the MCP server
93
+ ```
94
+
95
+ Requires Python 3.11+. Install and log in to the agent CLIs you want to use first.
96
+
97
+ ## Usage
98
+
99
+ ```bash
100
+ cli-relay status # what is installed / limited
101
+ cli-relay run "explain src/auth.py" # read-only by default
102
+ cli-relay run - < task.md --cwd ./repo --permission edit
103
+ cli-relay run "continue" --resume claude=SESSION_ID
104
+ cli-relay run "..." --providers antigravity,claude --format ndjson
105
+ cli-relay reset [provider] # forget recorded limits
106
+ ```
107
+
108
+ ### Permissions
109
+
110
+ `--permission` is mapped to each CLI's own permission system. Non-interactive runs never
111
+ wait for a prompt: anything that would ask is denied.
112
+
113
+ | `--permission` | Claude Code | Antigravity | Codex |
114
+ |---|---|---|---|
115
+ | `read_only` (default) | `--permission-mode dontAsk` | `--mode plan` | `--sandbox read-only` |
116
+ | `edit` | `--permission-mode acceptEdits` | `--mode accept-edits` | `--sandbox workspace-write` |
117
+ | `full` | `--permission-mode auto` | `--mode auto` | `workspace-write` + `--approve-for-me` |
118
+
119
+ A provider's `permission` setting is an upper bound, so a task never gets more than that.
120
+ `cli-relay` never uses "skip all permission checks" flags.
121
+
122
+ ## Configuration
123
+
124
+ `cli-relay` looks for its config file in this order:
125
+
126
+ 1. `--config PATH`
127
+ 2. `$CLI_RELAY_CONFIG`
128
+ 3. `./cli-relay.toml`
129
+ 4. `%APPDATA%\cli-relay\config.toml` on Windows, `~/.config/cli-relay/config.toml` elsewhere
130
+
131
+ Without a file the chain is `claude → antigravity → codex`. See
132
+ [`examples/cli-relay.toml`](examples/cli-relay.toml):
133
+
134
+ ```toml
135
+ order = ["claude", "antigravity", "codex", "openrouter", "local"]
136
+
137
+ [relay]
138
+ wait_if_reset_within_s = 900 # wait for a provider that resets within 15 min
139
+ skip_above_utilization = 0.97 # skip Claude when a usage window is ≥ 97 %
140
+ default_cooldown_s = 1800 # when a limit error carries no reset time
141
+ write_handoff_file = false # also write HANDOFF.md into --cwd on a switch
142
+
143
+ [providers.claude]
144
+ model = "opus"
145
+ permission = "edit" # upper bound for this provider
146
+
147
+ [providers.openrouter]
148
+ type = "openai"
149
+ base_url = "https://openrouter.ai/api/v1"
150
+ model = "vendor/model-id" # any OpenRouter model id
151
+ api_key_env = "OPENROUTER_API_KEY"
152
+
153
+ [providers.local]
154
+ type = "openai"
155
+ base_url = "http://127.0.0.1:8000/v1" # vLLM / Ollama / LM Studio
156
+ model = "your-served-model-name"
157
+ ```
158
+
159
+ Recorded limits live in `%LOCALAPPDATA%\cli-relay\state.json` on Windows and
160
+ `~/.local/state/cli-relay/state.json` elsewhere. Override the path with `$CLI_RELAY_STATE`.
161
+
162
+ ## As an MCP server
163
+
164
+ ```bash
165
+ claude mcp add cli-relay -- cli-relay mcp
166
+ ```
167
+
168
+ The server exposes two tools:
169
+ * `relay_run(prompt, cwd?, providers?, permission?)` returns `{ok, provider, text, session_id, switches, skipped}`.
170
+ * `relay_status()` returns installed providers and their known limits.
171
+
172
+ By default an MCP client can only request `read_only` work, whatever it asks for.
173
+ Otherwise a sandboxed agent could escalate by delegating to another one. To allow more,
174
+ set this in the config:
175
+
176
+ ```toml
177
+ [mcp]
178
+ max_permission = "edit"
179
+ ```
180
+
181
+ ## As a library
182
+
183
+ ```python
184
+ import asyncio
185
+ from cli_relay import Relay, Task, TextDelta, Completed
186
+ from cli_relay.config import load
187
+
188
+ async def main() -> None:
189
+ cfg = load()
190
+ relay = Relay(cfg.adapters(), cfg.quota(), cfg.policy)
191
+ async for event in relay.run(Task(prompt="summarise README.md")):
192
+ if isinstance(event, TextDelta):
193
+ print(event.text, end="")
194
+ elif isinstance(event, Completed):
195
+ print(f"\n-- by {event.provider}")
196
+
197
+ asyncio.run(main())
198
+ ```
199
+
200
+ ## Terms of service
201
+
202
+ `cli-relay` is meant for **your own** subscriptions on **your own** machine. It runs each
203
+ vendor's official CLI as a normal user would, and the vendors' terms still apply. For
204
+ unattended automation, the vendors recommend API keys over subscription logins. Do not
205
+ use `cli-relay` to share one subscription between several people.
206
+
207
+ ## Security notes
208
+
209
+ * Agent processes are started from an argument list, never through a shell.
210
+ * On Windows, `claude` and `codex` are npm `.cmd` shims, which `cmd.exe` re-parses. Prompts
211
+ and system prompts are therefore always sent through stdin or a temp file. `cli-relay`
212
+ refuses to put `cmd.exe` metacharacters on such a command line.
213
+ * Corrupted or interleaved NDJSON lines are salvaged rather than crashing a long run.
214
+
215
+ See [SECURITY.md](SECURITY.md) for how to report a vulnerability.
216
+
217
+ ## Roadmap
218
+
219
+ - Voice and push approvals for permission prompts (Claude Code `PermissionRequest` hook,
220
+ Codex app-server approvals)
221
+ - Codex app-server adapter with first-class rate-limit telemetry
222
+ - Git worktree per task with automatic WIP checkpoints
223
+ - Same-harness fallback (Claude Code against an Anthropic-compatible endpoint)
224
+
225
+ ## License
226
+
227
+ [Apache-2.0](LICENSE)
@@ -0,0 +1,25 @@
1
+ cli_relay/__init__.py,sha256=7SXYjS-7SXGqQ4l1McyMDO4rgBxIod2wotUJy3-evX8,717
2
+ cli_relay/__main__.py,sha256=7Hwal8yYdxsKc-0gXmz0qH-RkuzVSj4C8tX7wuCehdQ,61
3
+ cli_relay/cli.py,sha256=NOITCKfeNG3x2kdf-DjG1dRy_Ky4lUTwc3uu8FzmPVU,8791
4
+ cli_relay/config.py,sha256=5NBvYgyvmwzHfHrq3st2ysiTOkz1s0yVS24HwMPUcmo,4879
5
+ cli_relay/errors.py,sha256=oTHU5hrFIqBmjNu1GAmDNFtto73gpH6F61rmLypHf-M,3013
6
+ cli_relay/events.py,sha256=m-yp-Cyuc3DcFBdK-g3zzHKycpWiMxgZqxQsZsnVP88,2662
7
+ cli_relay/handoff.py,sha256=EXmQQVU0HFbsPAR-L1fNi98R9ZjVCLwKpHmOhllfmCs,2668
8
+ cli_relay/mcp_server.py,sha256=hgB9qkscUqraMiCpSUuOOJ9cmkQ8OeCWtjgrLgydJjI,4122
9
+ cli_relay/models.py,sha256=TxAUx5oWDnFsz-VgZQb0lzuvZSN9FUa7V575w0lLrKw,2318
10
+ cli_relay/ndjson.py,sha256=Vr2iahV9edbe8kk2-0gwzyqlTpU9j8z_pIffBb4nLLY,1153
11
+ cli_relay/proc.py,sha256=d2vt9LLgUfA2ZSXpCfpEAyoAth0QA5JuxxFB4F--_Dg,6748
12
+ cli_relay/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ cli_relay/quota.py,sha256=VmPR3BRl2_pbAz9UIzqYFI9qJ8-wHdDGPDMYsHGvra8,4700
14
+ cli_relay/relay.py,sha256=V2hIu6nj7yaT7FNuT8cWJinODABJzU8qkf0Tjj_hnA8,4946
15
+ cli_relay/adapters/__init__.py,sha256=lnsNmU5Dbs1jtbJWrG0kUBT7yYuqGJkD-CLjasPWAx0,1092
16
+ cli_relay/adapters/antigravity.py,sha256=hdxYgsU2iJr0HQhNo7niezR72iOeAewn3nZlsfPbF3U,4347
17
+ cli_relay/adapters/base.py,sha256=KYfIiW4PuArFFFIO49c3d1Cvm_3Wma6Hxdam9eoKHvg,5492
18
+ cli_relay/adapters/claude.py,sha256=uVRHgAe-X_jXGGz_dJfGP8vaa3IUF9aRBWwCDErHBdI,6471
19
+ cli_relay/adapters/codex.py,sha256=6hxkFUXPH1UIq6ayyjf_G72KsT1LDhld4gmJAXOlzso,4840
20
+ cli_relay/adapters/openai_compat.py,sha256=G8fJ9YfeLTsaSnHJ8NYazjQQG-cfFd9jH7T_fPGA9Ds,5576
21
+ cli_relay-0.1.0.dist-info/METADATA,sha256=BQpq9wqPvPd8q2dCJxS291kTiiRPB8DjRghaQfj4it8,9568
22
+ cli_relay-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
23
+ cli_relay-0.1.0.dist-info/entry_points.txt,sha256=zmAcRlUT8NX4-s0KIojlVEoTxBjAWjLBHi2NAe5KFkA,49
24
+ cli_relay-0.1.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
25
+ cli_relay-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cli-relay = cli_relay.cli:main