relayforge 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.
- relayforge/__init__.py +5 -0
- relayforge/_auto_guides.py +8 -0
- relayforge/agent_guides.py +92 -0
- relayforge/app.py +270 -0
- relayforge/cli.py +280 -0
- relayforge/cli_output.py +20 -0
- relayforge/cli_parser.py +120 -0
- relayforge/core/__init__.py +1 -0
- relayforge/core/config.py +27 -0
- relayforge/core/models.py +122 -0
- relayforge/core/providers.py +8 -0
- relayforge/core/router.py +84 -0
- relayforge/core/targets.py +15 -0
- relayforge/core/validation.py +67 -0
- relayforge/providers/__init__.py +1 -0
- relayforge/providers/discord/__init__.py +1 -0
- relayforge/providers/discord/gateway.py +136 -0
- relayforge/providers/discord/rest.py +75 -0
- relayforge/py.typed +1 -0
- relayforge/targets/__init__.py +1 -0
- relayforge/targets/codex/__init__.py +1 -0
- relayforge/targets/codex/jsonl.py +73 -0
- relayforge/targets/codex_app_server/__init__.py +6 -0
- relayforge/targets/codex_app_server/prompt.py +21 -0
- relayforge/targets/codex_app_server/target.py +159 -0
- relayforge/targets/codex_app_server/transport.py +160 -0
- relayforge-0.1.0.dist-info/METADATA +145 -0
- relayforge-0.1.0.dist-info/RECORD +32 -0
- relayforge-0.1.0.dist-info/WHEEL +4 -0
- relayforge-0.1.0.dist-info/entry_points.txt +2 -0
- relayforge-0.1.0.dist-info/licenses/LICENSE +21 -0
- relayforge_auto_guides.pth +1 -0
relayforge/__init__.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
GUIDE_START = "<!-- relayforge:start -->"
|
|
7
|
+
GUIDE_END = "<!-- relayforge:end -->"
|
|
8
|
+
LEGACY_GUIDE_START = "<!-- agent-relay:start -->"
|
|
9
|
+
LEGACY_GUIDE_END = "<!-- agent-relay:end -->"
|
|
10
|
+
|
|
11
|
+
GUIDE_BODY = (
|
|
12
|
+
"When this agent receives a Relayforge notification, treat it as user "
|
|
13
|
+
"""context from the configured chat binding.
|
|
14
|
+
|
|
15
|
+
To reply to the source channel, use:
|
|
16
|
+
|
|
17
|
+
relayforge binding-send --binding <binding-name> --message "..."
|
|
18
|
+
|
|
19
|
+
Use the binding name from the Relayforge notification.
|
|
20
|
+
|
|
21
|
+
If the user asks you to automatically listen for chat updates, validate setup
|
|
22
|
+
and start the listener:
|
|
23
|
+
|
|
24
|
+
relayforge check --config relay.config.json
|
|
25
|
+
relayforge listen --config relay.config.json
|
|
26
|
+
|
|
27
|
+
The listener is what turns new Discord messages into agent notifications.
|
|
28
|
+
"""
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
GUIDE_BLOCK = f"{GUIDE_START}\n{GUIDE_BODY}{GUIDE_END}\n"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def install_agent_guides(home: Path | None = None) -> list[Path]:
|
|
35
|
+
root = home or Path.home()
|
|
36
|
+
touched: list[Path] = []
|
|
37
|
+
touched.extend(_ensure_managed_block(root / ".codex" / "AGENTS.md"))
|
|
38
|
+
touched.extend(_ensure_managed_block(root / ".claude" / "CLAUDE.md"))
|
|
39
|
+
return touched
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def auto_install_agent_guides() -> None:
|
|
43
|
+
if os.getenv("RELAYFORGE_SKIP_AGENT_GUIDES"):
|
|
44
|
+
return
|
|
45
|
+
install_agent_guides()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _ensure_managed_block(path: Path) -> list[Path]:
|
|
49
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
existing = path.read_text() if path.exists() else ""
|
|
51
|
+
updated = _replace_or_append_block(existing)
|
|
52
|
+
if updated == existing:
|
|
53
|
+
return []
|
|
54
|
+
path.write_text(updated)
|
|
55
|
+
return [path]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _replace_or_append_block(existing: str) -> str:
|
|
59
|
+
start = existing.find(LEGACY_GUIDE_START)
|
|
60
|
+
end = existing.find(LEGACY_GUIDE_END)
|
|
61
|
+
if start != -1 and end != -1 and end > start:
|
|
62
|
+
after_end = end + len(LEGACY_GUIDE_END)
|
|
63
|
+
existing = f"{existing[:start]}{GUIDE_BLOCK}{existing[after_end:].lstrip()}"
|
|
64
|
+
|
|
65
|
+
start = existing.find(GUIDE_START)
|
|
66
|
+
end = existing.find(GUIDE_END)
|
|
67
|
+
if start != -1 and end != -1 and end > start:
|
|
68
|
+
after_end = end + len(GUIDE_END)
|
|
69
|
+
updated = f"{existing[:start]}{GUIDE_BLOCK}{existing[after_end:].lstrip()}"
|
|
70
|
+
return _remove_extra_managed_blocks(updated)
|
|
71
|
+
|
|
72
|
+
if existing.strip() == "":
|
|
73
|
+
return GUIDE_BLOCK
|
|
74
|
+
|
|
75
|
+
sep = "\n" if existing.endswith("\n") else "\n\n"
|
|
76
|
+
return f"{existing}{sep}{GUIDE_BLOCK}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _remove_extra_managed_blocks(existing: str) -> str:
|
|
80
|
+
first_end = existing.find(GUIDE_END)
|
|
81
|
+
if first_end == -1:
|
|
82
|
+
return existing
|
|
83
|
+
|
|
84
|
+
search_from = first_end + len(GUIDE_END)
|
|
85
|
+
while True:
|
|
86
|
+
start = existing.find(GUIDE_START, search_from)
|
|
87
|
+
end = existing.find(GUIDE_END, search_from)
|
|
88
|
+
if start == -1 or end == -1 or end < start:
|
|
89
|
+
return existing
|
|
90
|
+
after_end = end + len(GUIDE_END)
|
|
91
|
+
existing = f"{existing[:start]}{existing[after_end:].lstrip()}"
|
|
92
|
+
search_from = first_end + len(GUIDE_END)
|
relayforge/app.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from relayforge.core.config import empty_config, load_config, save_config
|
|
7
|
+
from relayforge.core.models import Binding, Channel, RelayMessage, Route
|
|
8
|
+
from relayforge.core.providers import ChannelSender
|
|
9
|
+
from relayforge.core.router import RelayRouter
|
|
10
|
+
from relayforge.core.validation import BindingValidator, check_config
|
|
11
|
+
from relayforge.providers.discord.gateway import (
|
|
12
|
+
DiscordGatewayClient,
|
|
13
|
+
GatewayListenResult,
|
|
14
|
+
)
|
|
15
|
+
from relayforge.providers.discord.rest import DiscordRestClient
|
|
16
|
+
from relayforge.targets.codex.jsonl import (
|
|
17
|
+
CodexJsonlTarget,
|
|
18
|
+
read_inbox,
|
|
19
|
+
read_inbox_with_cursor,
|
|
20
|
+
)
|
|
21
|
+
from relayforge.targets.codex_app_server import (
|
|
22
|
+
CodexAppServerTarget,
|
|
23
|
+
)
|
|
24
|
+
from relayforge.targets.codex_app_server.target import (
|
|
25
|
+
CodexThreadResumeResult,
|
|
26
|
+
CodexTurnStartResult,
|
|
27
|
+
)
|
|
28
|
+
from relayforge.targets.codex_app_server.transport import CodexAppServerTransport
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_router(config_path: str) -> RelayRouter:
|
|
32
|
+
config = load_config(config_path)
|
|
33
|
+
codex_jsonl = CodexJsonlTarget()
|
|
34
|
+
return RelayRouter(
|
|
35
|
+
config,
|
|
36
|
+
{"codex-jsonl": codex_jsonl},
|
|
37
|
+
{
|
|
38
|
+
"codex-jsonl": codex_jsonl,
|
|
39
|
+
"codex-app-server": CodexAppServerTarget(),
|
|
40
|
+
},
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def init_config(config_path: str, *, force: bool = False) -> Path:
|
|
45
|
+
path = Path(config_path)
|
|
46
|
+
if path.exists() and not force:
|
|
47
|
+
raise FileExistsError(f"Config already exists: {path}")
|
|
48
|
+
save_config(path, empty_config())
|
|
49
|
+
return path
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def list_channels(config_path: str) -> list[Channel]:
|
|
53
|
+
return load_config(config_path).channels
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def list_routes(config_path: str) -> list[Route]:
|
|
57
|
+
return load_config(config_path).routes
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def list_bindings(config_path: str) -> list[Binding]:
|
|
61
|
+
return load_config(config_path).bindings
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def check_config_path(config_path: str) -> list[str]:
|
|
65
|
+
return check_config(
|
|
66
|
+
load_config(config_path),
|
|
67
|
+
binding_validators=_binding_validators(),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def set_binding(
|
|
72
|
+
config_path: str,
|
|
73
|
+
*,
|
|
74
|
+
name: str,
|
|
75
|
+
source: str,
|
|
76
|
+
target: str,
|
|
77
|
+
source_thread_id: str | None = None,
|
|
78
|
+
codex_thread_id: str | None = None,
|
|
79
|
+
cwd: str | None = None,
|
|
80
|
+
machine: str | None = None,
|
|
81
|
+
transport: str | None = None,
|
|
82
|
+
inbox_path: str | None = None,
|
|
83
|
+
) -> Binding:
|
|
84
|
+
config = load_config(config_path)
|
|
85
|
+
binding = Binding(
|
|
86
|
+
name=name,
|
|
87
|
+
source=source,
|
|
88
|
+
target=target,
|
|
89
|
+
sourceThreadId=source_thread_id,
|
|
90
|
+
codexThreadId=codex_thread_id,
|
|
91
|
+
cwd=Path(cwd) if cwd is not None else None,
|
|
92
|
+
machine=machine,
|
|
93
|
+
transport=transport,
|
|
94
|
+
inboxPath=Path(inbox_path) if inbox_path is not None else None,
|
|
95
|
+
)
|
|
96
|
+
config.binding_channel(binding)
|
|
97
|
+
save_config(config_path, config.with_binding(binding))
|
|
98
|
+
return binding
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def set_channel(
|
|
102
|
+
config_path: str,
|
|
103
|
+
*,
|
|
104
|
+
name: str,
|
|
105
|
+
provider: str,
|
|
106
|
+
channel_id: str,
|
|
107
|
+
owner: str | None = None,
|
|
108
|
+
description: str | None = None,
|
|
109
|
+
) -> Channel:
|
|
110
|
+
config = load_config(config_path)
|
|
111
|
+
channel = Channel(
|
|
112
|
+
name=name,
|
|
113
|
+
provider=provider,
|
|
114
|
+
id=channel_id,
|
|
115
|
+
owner=owner,
|
|
116
|
+
description=description,
|
|
117
|
+
)
|
|
118
|
+
save_config(config_path, config.with_channel(channel))
|
|
119
|
+
return channel
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def set_route(
|
|
123
|
+
config_path: str,
|
|
124
|
+
*,
|
|
125
|
+
channel: str,
|
|
126
|
+
target: str,
|
|
127
|
+
inbox_path: str,
|
|
128
|
+
) -> Route:
|
|
129
|
+
config = load_config(config_path)
|
|
130
|
+
route = Route(
|
|
131
|
+
channel=channel,
|
|
132
|
+
target=target,
|
|
133
|
+
inboxPath=Path(inbox_path),
|
|
134
|
+
)
|
|
135
|
+
config.route_channel(route)
|
|
136
|
+
save_config(config_path, config.with_route(route))
|
|
137
|
+
return route
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _binding_validators() -> dict[str, BindingValidator]:
|
|
141
|
+
return {
|
|
142
|
+
"codex-app-server": _validate_codex_app_server_binding,
|
|
143
|
+
"codex-jsonl": _validate_codex_jsonl_binding,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _validate_codex_app_server_binding(binding: Binding) -> list[str]:
|
|
148
|
+
issues: list[str] = []
|
|
149
|
+
if binding.codex_thread_id is None:
|
|
150
|
+
issues.append(
|
|
151
|
+
f"Binding {binding.name} targets codex-app-server but lacks codexThreadId"
|
|
152
|
+
)
|
|
153
|
+
if binding.cwd is not None and not binding.cwd.is_dir():
|
|
154
|
+
issues.append(
|
|
155
|
+
f"Binding {binding.name} cwd does not exist or is not a directory: "
|
|
156
|
+
f"{binding.cwd}"
|
|
157
|
+
)
|
|
158
|
+
return issues
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _validate_codex_jsonl_binding(binding: Binding) -> list[str]:
|
|
162
|
+
if binding.inbox_path is not None:
|
|
163
|
+
return []
|
|
164
|
+
return [f"Binding {binding.name} targets codex-jsonl but lacks inboxPath"]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
async def smoke_codex_binding(
|
|
168
|
+
config_path: str,
|
|
169
|
+
binding_name: str,
|
|
170
|
+
*,
|
|
171
|
+
message: str,
|
|
172
|
+
resume_only: bool = False,
|
|
173
|
+
transport: CodexAppServerTransport | None = None,
|
|
174
|
+
) -> CodexThreadResumeResult | CodexTurnStartResult:
|
|
175
|
+
config = load_config(config_path)
|
|
176
|
+
binding = config.binding_by_name(binding_name)
|
|
177
|
+
if binding.target != "codex-app-server":
|
|
178
|
+
raise ValueError(
|
|
179
|
+
f"Binding {binding.name} targets {binding.target}, not codex-app-server"
|
|
180
|
+
)
|
|
181
|
+
target = CodexAppServerTarget(transport)
|
|
182
|
+
if resume_only:
|
|
183
|
+
return await target.resume_thread(binding)
|
|
184
|
+
return await target.start_turn(
|
|
185
|
+
RelayMessage(
|
|
186
|
+
channel=config.binding_channel(binding),
|
|
187
|
+
message_id="relayforge-smoke",
|
|
188
|
+
author_id="relayforge",
|
|
189
|
+
author_name="Relayforge Smoke",
|
|
190
|
+
content=message,
|
|
191
|
+
created_at=datetime.now(UTC),
|
|
192
|
+
thread_id=binding.source_thread_id,
|
|
193
|
+
),
|
|
194
|
+
binding,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def resolve_channel_id(config_path: str, channel: str) -> str:
|
|
199
|
+
if not Path(config_path).exists():
|
|
200
|
+
return channel
|
|
201
|
+
config = load_config(config_path)
|
|
202
|
+
for candidate in config.channels:
|
|
203
|
+
if candidate.name == channel or candidate.id == channel:
|
|
204
|
+
return candidate.id
|
|
205
|
+
return channel
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
async def listen(
|
|
209
|
+
config_path: str,
|
|
210
|
+
discord_token: str,
|
|
211
|
+
*,
|
|
212
|
+
max_messages: int | None = None,
|
|
213
|
+
timeout_seconds: float | None = None,
|
|
214
|
+
include_bots: bool = False,
|
|
215
|
+
) -> GatewayListenResult:
|
|
216
|
+
router = build_router(config_path)
|
|
217
|
+
gateway = DiscordGatewayClient(discord_token)
|
|
218
|
+
return await gateway.listen(
|
|
219
|
+
router.route,
|
|
220
|
+
max_messages=max_messages,
|
|
221
|
+
timeout_seconds=timeout_seconds,
|
|
222
|
+
include_bots=include_bots,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def send_discord(channel_id: str, message: str, discord_token: str) -> None:
|
|
227
|
+
client = DiscordRestClient(discord_token)
|
|
228
|
+
await client.send_message(channel_id, message)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
async def send_binding(
|
|
232
|
+
config_path: str,
|
|
233
|
+
binding_name: str,
|
|
234
|
+
message: str,
|
|
235
|
+
discord_token: str,
|
|
236
|
+
*,
|
|
237
|
+
senders: dict[str, ChannelSender] | None = None,
|
|
238
|
+
) -> None:
|
|
239
|
+
config = load_config(config_path)
|
|
240
|
+
binding = config.binding_by_name(binding_name)
|
|
241
|
+
channel = config.binding_channel(binding)
|
|
242
|
+
provider_senders = senders or {"discord": DiscordRestClient(discord_token)}
|
|
243
|
+
sender = provider_senders.get(channel.provider)
|
|
244
|
+
if sender is None:
|
|
245
|
+
raise LookupError(f"No sender registered for provider {channel.provider}")
|
|
246
|
+
await sender.send_message(binding.source_thread_id or channel.id, message)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
async def read_discord(
|
|
250
|
+
channel_id: str,
|
|
251
|
+
discord_token: str,
|
|
252
|
+
limit: int = 10,
|
|
253
|
+
) -> list[RelayMessage]:
|
|
254
|
+
client = DiscordRestClient(discord_token)
|
|
255
|
+
return await client.read_messages(channel_id, limit)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def read_codex_inbox(
|
|
259
|
+
path: str,
|
|
260
|
+
limit: int | None = None,
|
|
261
|
+
*,
|
|
262
|
+
cursor_path: str | None = None,
|
|
263
|
+
ack: bool = False,
|
|
264
|
+
) -> list[RelayMessage]:
|
|
265
|
+
if cursor_path is not None:
|
|
266
|
+
return read_inbox_with_cursor(path, cursor_path, limit=limit, ack=ack)
|
|
267
|
+
messages = read_inbox(path)
|
|
268
|
+
if limit is None:
|
|
269
|
+
return messages
|
|
270
|
+
return messages[-limit:]
|
relayforge/cli.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from relayforge.app import (
|
|
9
|
+
check_config_path,
|
|
10
|
+
init_config,
|
|
11
|
+
list_bindings,
|
|
12
|
+
list_channels,
|
|
13
|
+
list_routes,
|
|
14
|
+
listen,
|
|
15
|
+
read_codex_inbox,
|
|
16
|
+
read_discord,
|
|
17
|
+
resolve_channel_id,
|
|
18
|
+
send_binding,
|
|
19
|
+
send_discord,
|
|
20
|
+
set_binding,
|
|
21
|
+
set_channel,
|
|
22
|
+
set_route,
|
|
23
|
+
smoke_codex_binding,
|
|
24
|
+
)
|
|
25
|
+
from relayforge.cli_output import print_json_object, print_model, print_models
|
|
26
|
+
from relayforge.cli_parser import build_parser
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
30
|
+
args = build_parser().parse_args(argv)
|
|
31
|
+
|
|
32
|
+
if args.command == "channels":
|
|
33
|
+
_print_channels(args.config)
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
if args.command == "init":
|
|
37
|
+
_init_config(args.config, force=args.force)
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
if args.command == "routes":
|
|
41
|
+
_print_routes(args.config)
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
if args.command == "bindings":
|
|
45
|
+
_print_bindings(args.config)
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
if args.command == "channel-set":
|
|
49
|
+
_set_channel(args)
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
if args.command == "route-set":
|
|
53
|
+
_set_route(args)
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
if args.command == "binding-set":
|
|
57
|
+
_set_binding(args)
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
if args.command == "check":
|
|
61
|
+
_check_config(args.config, json_output=args.json)
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
if args.command == "inbox":
|
|
65
|
+
_print_inbox(
|
|
66
|
+
args.path,
|
|
67
|
+
args.limit,
|
|
68
|
+
cursor_path=args.cursor,
|
|
69
|
+
ack=args.ack,
|
|
70
|
+
)
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
if args.command == "codex-smoke":
|
|
74
|
+
_run_codex_smoke(
|
|
75
|
+
args.config,
|
|
76
|
+
args.binding,
|
|
77
|
+
message=args.message,
|
|
78
|
+
resume_only=args.resume_only,
|
|
79
|
+
)
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
token = _required_env("DISCORD_BOT_TOKEN")
|
|
83
|
+
|
|
84
|
+
if args.command == "listen":
|
|
85
|
+
_run_listen(
|
|
86
|
+
args.config,
|
|
87
|
+
token,
|
|
88
|
+
max_messages=args.max_messages,
|
|
89
|
+
timeout_seconds=args.timeout,
|
|
90
|
+
include_bots=args.include_bots,
|
|
91
|
+
)
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
if args.command == "read":
|
|
95
|
+
_print_discord_messages(args.config, args.channel, token, args.limit)
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
if args.command == "send":
|
|
99
|
+
_send_discord_message(args.config, args.channel, token, args.message)
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
if args.command == "binding-send":
|
|
103
|
+
_send_binding_message(args.config, args.binding, token, args.message)
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _print_channels(config_path: str) -> None:
|
|
108
|
+
print_models(list_channels(config_path))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _init_config(config_path: str, *, force: bool) -> None:
|
|
112
|
+
try:
|
|
113
|
+
path = init_config(config_path, force=force)
|
|
114
|
+
except FileExistsError as exc:
|
|
115
|
+
raise SystemExit(str(exc)) from exc
|
|
116
|
+
print(f"created {path}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _print_routes(config_path: str) -> None:
|
|
120
|
+
print_models(list_routes(config_path), by_alias=True)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _print_bindings(config_path: str) -> None:
|
|
124
|
+
print_models(list_bindings(config_path), by_alias=True)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _set_channel(args: argparse.Namespace) -> None:
|
|
128
|
+
channel = set_channel(
|
|
129
|
+
args.config,
|
|
130
|
+
name=args.name,
|
|
131
|
+
provider=args.provider,
|
|
132
|
+
channel_id=args.id,
|
|
133
|
+
owner=args.owner,
|
|
134
|
+
description=args.description,
|
|
135
|
+
)
|
|
136
|
+
print_model(channel)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _set_route(args: argparse.Namespace) -> None:
|
|
140
|
+
try:
|
|
141
|
+
route = set_route(
|
|
142
|
+
args.config,
|
|
143
|
+
channel=args.channel,
|
|
144
|
+
target=args.target,
|
|
145
|
+
inbox_path=args.inbox_path,
|
|
146
|
+
)
|
|
147
|
+
except LookupError as exc:
|
|
148
|
+
raise SystemExit(str(exc)) from exc
|
|
149
|
+
print_model(route, by_alias=True)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _set_binding(args: argparse.Namespace) -> None:
|
|
153
|
+
try:
|
|
154
|
+
binding = set_binding(
|
|
155
|
+
args.config,
|
|
156
|
+
name=args.name,
|
|
157
|
+
source=args.source,
|
|
158
|
+
target=args.target,
|
|
159
|
+
source_thread_id=args.source_thread_id,
|
|
160
|
+
codex_thread_id=args.codex_thread_id,
|
|
161
|
+
cwd=args.cwd,
|
|
162
|
+
machine=args.machine,
|
|
163
|
+
transport=args.transport,
|
|
164
|
+
inbox_path=args.inbox_path,
|
|
165
|
+
)
|
|
166
|
+
except LookupError as exc:
|
|
167
|
+
raise SystemExit(str(exc)) from exc
|
|
168
|
+
print_model(binding, by_alias=True)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _check_config(config_path: str, *, json_output: bool) -> None:
|
|
172
|
+
issues = check_config_path(config_path)
|
|
173
|
+
if json_output:
|
|
174
|
+
print_json_object({"ok": not issues, "issues": issues})
|
|
175
|
+
if issues:
|
|
176
|
+
raise SystemExit(1)
|
|
177
|
+
return
|
|
178
|
+
if not issues:
|
|
179
|
+
print("ok")
|
|
180
|
+
return
|
|
181
|
+
for issue in issues:
|
|
182
|
+
print(issue)
|
|
183
|
+
raise SystemExit(1)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _print_inbox(
|
|
187
|
+
path: str,
|
|
188
|
+
limit: int | None,
|
|
189
|
+
*,
|
|
190
|
+
cursor_path: str | None,
|
|
191
|
+
ack: bool,
|
|
192
|
+
) -> None:
|
|
193
|
+
for message in read_codex_inbox(
|
|
194
|
+
path,
|
|
195
|
+
limit,
|
|
196
|
+
cursor_path=cursor_path,
|
|
197
|
+
ack=ack,
|
|
198
|
+
):
|
|
199
|
+
print_model(message)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _run_codex_smoke(
|
|
203
|
+
config_path: str,
|
|
204
|
+
binding: str,
|
|
205
|
+
*,
|
|
206
|
+
message: str,
|
|
207
|
+
resume_only: bool,
|
|
208
|
+
) -> None:
|
|
209
|
+
try:
|
|
210
|
+
result = asyncio.run(
|
|
211
|
+
smoke_codex_binding(
|
|
212
|
+
config_path,
|
|
213
|
+
binding,
|
|
214
|
+
message=message,
|
|
215
|
+
resume_only=resume_only,
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
except (LookupError, ValueError) as exc:
|
|
219
|
+
raise SystemExit(str(exc)) from exc
|
|
220
|
+
print_json_object({"threadId": result.thread_id, "method": result.method})
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _run_listen(
|
|
224
|
+
config_path: str,
|
|
225
|
+
discord_token: str,
|
|
226
|
+
*,
|
|
227
|
+
max_messages: int | None,
|
|
228
|
+
timeout_seconds: float | None,
|
|
229
|
+
include_bots: bool,
|
|
230
|
+
) -> None:
|
|
231
|
+
count = asyncio.run(
|
|
232
|
+
listen(
|
|
233
|
+
config_path,
|
|
234
|
+
discord_token,
|
|
235
|
+
max_messages=max_messages,
|
|
236
|
+
timeout_seconds=timeout_seconds,
|
|
237
|
+
include_bots=include_bots,
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
print(f"received={count.received} routed={count.routed}")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _print_discord_messages(
|
|
244
|
+
config_path: str,
|
|
245
|
+
channel: str,
|
|
246
|
+
discord_token: str,
|
|
247
|
+
limit: int,
|
|
248
|
+
) -> None:
|
|
249
|
+
channel_id = resolve_channel_id(config_path, channel)
|
|
250
|
+
messages = asyncio.run(read_discord(channel_id, discord_token, limit))
|
|
251
|
+
print_models(messages)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _send_discord_message(
|
|
255
|
+
config_path: str,
|
|
256
|
+
channel: str,
|
|
257
|
+
discord_token: str,
|
|
258
|
+
message: str,
|
|
259
|
+
) -> None:
|
|
260
|
+
channel_id = resolve_channel_id(config_path, channel)
|
|
261
|
+
asyncio.run(send_discord(channel_id, message, discord_token))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _send_binding_message(
|
|
265
|
+
config_path: str,
|
|
266
|
+
binding: str,
|
|
267
|
+
discord_token: str,
|
|
268
|
+
message: str,
|
|
269
|
+
) -> None:
|
|
270
|
+
try:
|
|
271
|
+
asyncio.run(send_binding(config_path, binding, message, discord_token))
|
|
272
|
+
except LookupError as exc:
|
|
273
|
+
raise SystemExit(str(exc)) from exc
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _required_env(name: str) -> str:
|
|
277
|
+
value = os.getenv(name)
|
|
278
|
+
if not value:
|
|
279
|
+
raise SystemExit(f"Missing {name}")
|
|
280
|
+
return value
|
relayforge/cli_output.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def print_json_object(payload: dict[str, Any]) -> None:
|
|
11
|
+
print(json.dumps(payload))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def print_model(model: BaseModel, *, by_alias: bool = False) -> None:
|
|
15
|
+
print(model.model_dump_json(by_alias=by_alias))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def print_models(models: Iterable[BaseModel], *, by_alias: bool = False) -> None:
|
|
19
|
+
for model in models:
|
|
20
|
+
print_model(model, by_alias=by_alias)
|