anywhere-cli 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.
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from typing import Any
6
+
7
+
8
+ class ClaudeTimelineIdentity:
9
+ @staticmethod
10
+ def message(*, session_id: str, claude_session_id: str, message_id: str) -> str:
11
+ return f"claude_msg_{_short('message', claude_session_id, message_id)}"
12
+
13
+ @staticmethod
14
+ def tool_call(
15
+ *,
16
+ session_id: str,
17
+ claude_session_id: str,
18
+ tool_use_id: str,
19
+ ) -> str:
20
+ return f"claude_tool_{_short('tool', claude_session_id, tool_use_id)}"
21
+
22
+ @staticmethod
23
+ def tool_result(
24
+ *,
25
+ session_id: str,
26
+ claude_session_id: str,
27
+ tool_use_id: str,
28
+ ) -> str:
29
+ return ClaudeTimelineIdentity.tool_call(
30
+ session_id=session_id,
31
+ claude_session_id=claude_session_id,
32
+ tool_use_id=tool_use_id,
33
+ )
34
+
35
+ @staticmethod
36
+ def derived(*values: Any) -> str:
37
+ return f"claude_derived_{_short(*values)}"
38
+
39
+
40
+ def content_hash(*values: Any) -> str:
41
+ payload = json.dumps(values, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
42
+ return "sha256:" + hashlib.sha256(payload.encode("utf-8")).hexdigest()
43
+
44
+
45
+ def _short(*values: Any) -> str:
46
+ payload = json.dumps(values, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
47
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:24]
@@ -0,0 +1,379 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from connector.claude.normalized import NormalizedClaudeEvent
7
+ from connector.claude.timeline_identity import ClaudeTimelineIdentity, content_hash
8
+
9
+
10
+ class ClaudeTimelineReducer:
11
+ def reduce(
12
+ self,
13
+ *,
14
+ session_id: str,
15
+ turn_id: str,
16
+ events: list[NormalizedClaudeEvent],
17
+ ) -> list[dict[str, Any]]:
18
+ items: dict[str, dict[str, Any]] = {}
19
+ ignored_tool_use_ids: set[str] = set()
20
+ order_seq = 1
21
+ for event in events:
22
+ if event.toolUseId and event.toolResult is None and is_task_event_tool_name(event.toolName):
23
+ ignored_tool_use_ids.add(event.toolUseId)
24
+ continue
25
+ if event.toolUseId and event.toolResult is not None and event.toolUseId in ignored_tool_use_ids:
26
+ continue
27
+ item = self._reduce_event(session_id=session_id, turn_id=turn_id, event=event, order_seq=order_seq)
28
+ if item is None:
29
+ continue
30
+ existing = items.get(item["id"])
31
+ if existing is None:
32
+ items[item["id"]] = item
33
+ order_seq += 1
34
+ continue
35
+ items[item["id"]] = _merge_item(existing, item)
36
+ return sorted(items.values(), key=lambda item: int(item.get("orderSeq") or 0))
37
+
38
+ def _reduce_event(
39
+ self,
40
+ *,
41
+ session_id: str,
42
+ turn_id: str,
43
+ event: NormalizedClaudeEvent,
44
+ order_seq: int,
45
+ ) -> dict[str, Any] | None:
46
+ message_id = event.messageId or event.sourceEventId
47
+ if event.toolUseId and event.toolResult is not None:
48
+ item_id = ClaudeTimelineIdentity.tool_result(
49
+ session_id=session_id,
50
+ claude_session_id=event.claudeSessionId,
51
+ tool_use_id=event.toolUseId,
52
+ )
53
+ content = _tool_result_content(event)
54
+ status = "failed" if event.toolResultIsError else "done"
55
+ return {
56
+ "id": item_id,
57
+ "sessionId": session_id,
58
+ "turnId": turn_id,
59
+ "type": "tool",
60
+ "status": status,
61
+ "role": "tool",
62
+ "content": content,
63
+ "source": _source(event, turn_id, "tool_result"),
64
+ "orderSeq": order_seq,
65
+ "revision": 1,
66
+ "contentHash": content_hash(content),
67
+ "createdAt": event.timestamp,
68
+ "updatedAt": event.timestamp,
69
+ "completedAt": event.timestamp,
70
+ }
71
+
72
+ if event.toolUseId:
73
+ item_id = ClaudeTimelineIdentity.tool_call(
74
+ session_id=session_id,
75
+ claude_session_id=event.claudeSessionId,
76
+ tool_use_id=event.toolUseId,
77
+ )
78
+ content = _tool_call_content(event)
79
+ return {
80
+ "id": item_id,
81
+ "sessionId": session_id,
82
+ "turnId": turn_id,
83
+ "type": "tool",
84
+ "status": "running",
85
+ "role": "tool",
86
+ "content": content,
87
+ "source": _source(event, turn_id, "tool_use"),
88
+ "orderSeq": order_seq,
89
+ "revision": 1,
90
+ "contentHash": content_hash(content),
91
+ "createdAt": event.timestamp,
92
+ "updatedAt": event.timestamp,
93
+ }
94
+
95
+ if event.role in {"user", "assistant", "system"} and event.text is not None and event.text.strip():
96
+ item_id = ClaudeTimelineIdentity.message(
97
+ session_id=session_id,
98
+ claude_session_id=event.claudeSessionId,
99
+ message_id=message_id,
100
+ )
101
+ content = {"text": event.text}
102
+ if event.attachments:
103
+ content["attachments"] = event.attachments
104
+ source = _source(event, turn_id, "message")
105
+ if event.clientMessageId:
106
+ source["clientMessageId"] = event.clientMessageId
107
+ return {
108
+ "id": item_id,
109
+ "sessionId": session_id,
110
+ "turnId": turn_id,
111
+ "type": "message",
112
+ "status": "done",
113
+ "role": event.role,
114
+ "content": content,
115
+ "source": source,
116
+ "orderSeq": order_seq,
117
+ "revision": 1,
118
+ "contentHash": content_hash(content),
119
+ "createdAt": event.timestamp,
120
+ "updatedAt": event.timestamp,
121
+ "completedAt": event.timestamp,
122
+ }
123
+ return None
124
+
125
+
126
+ def _source(event: NormalizedClaudeEvent, turn_id: str, derived_key: str) -> dict[str, Any]:
127
+ return {
128
+ "runtime": "claude",
129
+ "sessionId": event.claudeSessionId,
130
+ "turnId": turn_id,
131
+ "itemId": event.toolUseId or event.messageId or event.sourceEventId,
132
+ "itemType": event.blockType,
133
+ "event": event.sourceEventId,
134
+ "derivedKey": derived_key,
135
+ }
136
+
137
+
138
+ def _tool_kind(tool_name: str | None) -> str:
139
+ if _mcp_parts(tool_name) is not None:
140
+ return "mcp"
141
+ if tool_name in {"Edit", "Write", "NotebookEdit"}:
142
+ return "file_change"
143
+ if tool_name == "MultiEdit":
144
+ return "file_change"
145
+ if tool_name == "Bash":
146
+ return "command"
147
+ if tool_name in {"WebFetch", "WebSearch"}:
148
+ return "web_search"
149
+ return "tool"
150
+
151
+
152
+ def is_task_event_tool_name(tool_name: str | None) -> bool:
153
+ """Claude Code task bookkeeping tools should not become user-visible tools.
154
+
155
+ Keep the real Claude sub-agent tool named exactly "Task"; hide status/event
156
+ tools such as TaskCreate and TaskUpdate.
157
+ """
158
+ return bool(tool_name and tool_name != "Task" and tool_name.startswith("Task") and len(tool_name) > 4 and tool_name[4].isupper())
159
+
160
+
161
+ def _merge_item(existing: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]:
162
+ if existing.get("type") == "tool" and incoming.get("status") in {"done", "failed", "interrupted", "cancelled"}:
163
+ data = dict(existing)
164
+ content = dict(data.get("content") or {})
165
+ incoming_content = incoming.get("content")
166
+ content.update(incoming_content if isinstance(incoming_content, dict) else {})
167
+ data["content"] = content
168
+ data["status"] = incoming.get("status")
169
+ data["role"] = incoming.get("role") or data.get("role")
170
+ data["revision"] = int(data.get("revision") or 1) + 1
171
+ data["contentHash"] = content_hash(content)
172
+ data["updatedAt"] = incoming.get("updatedAt")
173
+ data["completedAt"] = incoming.get("completedAt")
174
+ return data
175
+ if _content_score(incoming.get("content")) > _content_score(existing.get("content")):
176
+ return incoming
177
+ return existing
178
+
179
+
180
+ def _content_score(content: Any) -> int:
181
+ return len(str(content or ""))
182
+
183
+
184
+ def _tool_call_content(event: NormalizedClaudeEvent) -> dict[str, Any]:
185
+ tool_name = event.toolName or "tool"
186
+ tool_input = event.toolInput if isinstance(event.toolInput, dict) else event.toolInput
187
+ common = _tool_common(event)
188
+ kind = _tool_kind(tool_name)
189
+ if kind == "command":
190
+ input_data = tool_input if isinstance(tool_input, dict) else {}
191
+ command = _string(input_data.get("command") or input_data.get("cmd")) or ""
192
+ content = {
193
+ **common,
194
+ "kind": "command",
195
+ "command": command,
196
+ "description": _string(input_data.get("description")) or command,
197
+ "cwd": _string(input_data.get("cwd")),
198
+ }
199
+ if isinstance(input_data.get("run_in_background"), bool):
200
+ content["runInBackground"] = input_data.get("run_in_background")
201
+ return _strip_empty(content)
202
+
203
+ if kind == "file_change":
204
+ return _strip_empty({
205
+ **common,
206
+ "kind": "file_change",
207
+ "changes": _file_changes(tool_name, tool_input),
208
+ })
209
+
210
+ if kind == "web_search":
211
+ input_data = tool_input if isinstance(tool_input, dict) else {}
212
+ return _strip_empty({
213
+ **common,
214
+ "kind": "web_search",
215
+ "query": _string(input_data.get("query")),
216
+ "url": _string(input_data.get("url")),
217
+ "action": input_data,
218
+ })
219
+
220
+ mcp = _mcp_parts(tool_name)
221
+ if mcp is not None:
222
+ server, tool = mcp
223
+ return _strip_empty({
224
+ **common,
225
+ "kind": "mcp",
226
+ "server": server,
227
+ "tool": tool,
228
+ "arguments": tool_input if isinstance(tool_input, dict) else {},
229
+ "result": None,
230
+ "error": None,
231
+ })
232
+
233
+ return _strip_empty({
234
+ **common,
235
+ "kind": "tool",
236
+ "name": tool_name,
237
+ "tool": tool_name,
238
+ "arguments": tool_input if isinstance(tool_input, dict) else tool_input,
239
+ })
240
+
241
+
242
+ def _tool_result_content(event: NormalizedClaudeEvent) -> dict[str, Any]:
243
+ text = _result_text(event.toolResult)
244
+ content: dict[str, Any] = {
245
+ "toolUseId": event.toolUseId,
246
+ "result": event.toolResult,
247
+ "text": text,
248
+ "outputText": text,
249
+ "outputPreview": _preview_text(text),
250
+ "outputLength": len(text),
251
+ }
252
+ if event.toolResultIsError:
253
+ content["isError"] = True
254
+ content["error"] = text
255
+ return content
256
+
257
+
258
+ def _tool_common(event: NormalizedClaudeEvent) -> dict[str, Any]:
259
+ return {
260
+ "toolUseId": event.toolUseId,
261
+ "toolName": event.toolName,
262
+ "input": event.toolInput,
263
+ }
264
+
265
+
266
+ def _file_changes(tool_name: str, tool_input: Any) -> list[dict[str, Any]]:
267
+ input_data = tool_input if isinstance(tool_input, dict) else {}
268
+ if tool_name == "Write":
269
+ path = _string(input_data.get("file_path") or input_data.get("path")) or ""
270
+ text = _string(input_data.get("content")) or ""
271
+ return [_strip_empty({
272
+ "path": path,
273
+ "action": "add",
274
+ "kind": {"type": "add"},
275
+ "diff": text,
276
+ })]
277
+ if tool_name == "Edit":
278
+ path = _string(input_data.get("file_path") or input_data.get("path")) or ""
279
+ return [_strip_empty({
280
+ "path": path,
281
+ "action": "update",
282
+ "kind": {"type": "update"},
283
+ "diff": _edit_diff(
284
+ path,
285
+ _string(input_data.get("old_string")) or "",
286
+ _string(input_data.get("new_string")) or "",
287
+ ),
288
+ })]
289
+ if tool_name == "MultiEdit":
290
+ path = _string(input_data.get("file_path") or input_data.get("path")) or ""
291
+ edits = input_data.get("edits")
292
+ if not isinstance(edits, list):
293
+ edits = []
294
+ diff_parts = []
295
+ for edit in edits:
296
+ if not isinstance(edit, dict):
297
+ continue
298
+ diff_parts.append(
299
+ _edit_diff(
300
+ path,
301
+ _string(edit.get("old_string")) or "",
302
+ _string(edit.get("new_string")) or "",
303
+ include_header=not diff_parts,
304
+ )
305
+ )
306
+ return [_strip_empty({
307
+ "path": path,
308
+ "action": "update",
309
+ "kind": {"type": "update"},
310
+ "diff": "\n".join(part for part in diff_parts if part),
311
+ })]
312
+ if tool_name == "NotebookEdit":
313
+ path = _string(input_data.get("notebook_path") or input_data.get("file_path") or input_data.get("path")) or ""
314
+ new_source = _string(input_data.get("new_source")) or _json_text(input_data.get("new_source"))
315
+ return [_strip_empty({
316
+ "path": path,
317
+ "action": "update",
318
+ "kind": {"type": "update"},
319
+ "diff": _edit_diff(path, "", new_source),
320
+ })]
321
+ path = _string(input_data.get("file_path") or input_data.get("path")) or ""
322
+ return [_strip_empty({"path": path, "action": "update", "kind": {"type": "update"}})]
323
+
324
+
325
+ def _edit_diff(path: str, old: str, new: str, *, include_header: bool = True) -> str:
326
+ lines: list[str] = []
327
+ if include_header:
328
+ lines.extend([f"--- {path}", f"+++ {path}"])
329
+ lines.append("@@")
330
+ if old:
331
+ lines.extend(f"-{line}" for line in old.splitlines())
332
+ if new:
333
+ lines.extend(f"+{line}" for line in new.splitlines())
334
+ return "\n".join(lines)
335
+
336
+
337
+ def _mcp_parts(tool_name: str | None) -> tuple[str, str] | None:
338
+ if not tool_name or not tool_name.startswith("mcp__"):
339
+ return None
340
+ parts = tool_name.split("__", 2)
341
+ if len(parts) != 3 or not parts[1] or not parts[2]:
342
+ return None
343
+ return parts[1], parts[2]
344
+
345
+
346
+ def _result_text(value: Any) -> str:
347
+ if isinstance(value, str):
348
+ return value
349
+ if isinstance(value, list):
350
+ texts: list[str] = []
351
+ for item in value:
352
+ if isinstance(item, dict) and isinstance(item.get("text"), str):
353
+ texts.append(item["text"])
354
+ elif isinstance(item, str):
355
+ texts.append(item)
356
+ if texts:
357
+ return "\n".join(texts)
358
+ if value is None:
359
+ return ""
360
+ return _json_text(value)
361
+
362
+
363
+ def _json_text(value: Any) -> str:
364
+ try:
365
+ return json.dumps(value, ensure_ascii=False, indent=2)
366
+ except TypeError:
367
+ return str(value)
368
+
369
+
370
+ def _preview_text(value: str, limit: int = 4000) -> str:
371
+ return value[-limit:]
372
+
373
+
374
+ def _string(value: Any) -> str | None:
375
+ return value if isinstance(value, str) else None
376
+
377
+
378
+ def _strip_empty(value: dict[str, Any]) -> dict[str, Any]:
379
+ return {key: item for key, item in value.items() if item is not None}
@@ -0,0 +1,69 @@
1
+ """Pre-accept the per-cwd Claude trust dialog.
2
+
3
+ First time you point `claude` at a brand-new directory, the TUI pops a
4
+ 'Is this a project you trust?' prompt that blocks everything until you
5
+ say yes. The decision is persisted in `~/.claude.json` under
6
+ `projects[<cwd>].hasTrustDialogAccepted`.
7
+
8
+ Daemon-driven spawns shouldn't see that dialog at all. This module writes
9
+ the flag *before* spawn. Research doc §3.3: also write the resolved cwd
10
+ because macOS may resolve symlinks before the prompt logic runs.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from pathlib import Path
17
+
18
+ from loguru import logger
19
+
20
+
21
+ def _claude_config_path() -> Path:
22
+ return Path.home() / ".claude.json"
23
+
24
+
25
+ def ensure_trust(cwd: str | Path, *, config_path: Path | None = None) -> None:
26
+ """Idempotently mark `cwd` (and its resolved form) as trusted."""
27
+ path = config_path or _claude_config_path()
28
+ candidates = _trust_candidates(cwd)
29
+
30
+ try:
31
+ data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
32
+ except (OSError, json.JSONDecodeError) as exc:
33
+ logger.warning("trust: ~/.claude.json unreadable, starting fresh ({})", exc)
34
+ data = {}
35
+ if not isinstance(data, dict):
36
+ data = {}
37
+
38
+ projects = data.get("projects")
39
+ if not isinstance(projects, dict):
40
+ projects = {}
41
+ data["projects"] = projects
42
+
43
+ changed = False
44
+ for candidate in candidates:
45
+ entry = projects.get(candidate)
46
+ if not isinstance(entry, dict):
47
+ entry = {}
48
+ projects[candidate] = entry
49
+ if not entry.get("hasTrustDialogAccepted"):
50
+ entry["hasTrustDialogAccepted"] = True
51
+ changed = True
52
+
53
+ if not changed:
54
+ return
55
+
56
+ path.parent.mkdir(parents=True, exist_ok=True)
57
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
58
+
59
+
60
+ def _trust_candidates(cwd: str | Path) -> list[str]:
61
+ raw = str(cwd)
62
+ candidates = [raw]
63
+ try:
64
+ resolved = str(Path(cwd).resolve())
65
+ except OSError:
66
+ resolved = raw
67
+ if resolved != raw:
68
+ candidates.append(resolved)
69
+ return candidates
connector/cli.py ADDED
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import os
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+
10
+ import httpx
11
+
12
+ from connector.runtime import BackendRpcClient, ConnectorConfig
13
+
14
+
15
+ def main(argv: list[str] | None = None) -> None:
16
+ parser = _build_parser()
17
+ args = parser.parse_args(argv)
18
+ try:
19
+ if args.command == "login":
20
+ asyncio.run(_login(args))
21
+ elif args.command == "configure":
22
+ _configure(args)
23
+ elif args.command == "start":
24
+ asyncio.run(_start(args))
25
+ else:
26
+ parser.print_help()
27
+ except KeyboardInterrupt:
28
+ raise SystemExit(130) from None
29
+
30
+
31
+ def _build_parser() -> argparse.ArgumentParser:
32
+ parser = argparse.ArgumentParser(prog="anywhere-cli", description="Agent Server Codex connector CLI")
33
+ subparsers = parser.add_subparsers(dest="command")
34
+
35
+ start = subparsers.add_parser("start", help="start the connector")
36
+ _add_config_args(start)
37
+ start.add_argument("--server-url", help="backend server URL")
38
+ start.add_argument("--connector-id", help="connector id")
39
+ start.add_argument("--connector-token", help="connector token")
40
+
41
+ login = subparsers.add_parser("login", help="pair with a backend, save credentials, and start the connector")
42
+ _add_config_args(login)
43
+ login.add_argument("--server-url", required=True, help="backend server URL")
44
+ login.add_argument("--poll-interval", type=float, default=2, help="seconds between pairing polls")
45
+ login.add_argument("--timeout", type=float, default=600, help="pairing timeout in seconds")
46
+ login.add_argument("--no-start", action="store_true", help="save credentials without starting the connector")
47
+
48
+ configure = subparsers.add_parser("configure", help="save connector credentials to local JSON")
49
+ _add_config_args(configure)
50
+ configure.add_argument("--server-url", required=True, help="backend server URL")
51
+ configure.add_argument("--connector-id", required=True, help="connector id")
52
+ configure.add_argument("--connector-token", required=True, help="connector token")
53
+ return parser
54
+
55
+
56
+ def _add_config_args(parser: argparse.ArgumentParser) -> None:
57
+ parser.add_argument(
58
+ "--config",
59
+ default=str(ConnectorConfig.default_path()),
60
+ help="local connector config JSON path",
61
+ )
62
+
63
+
64
+ async def _start(args: argparse.Namespace) -> None:
65
+ config = _resolve_config(args)
66
+ await BackendRpcClient(config).run_forever()
67
+
68
+
69
+ async def _login(args: argparse.Namespace) -> None:
70
+ server_url = args.server_url.rstrip("/")
71
+ async with httpx.AsyncClient(timeout=30) as client:
72
+ start_response = await client.post(
73
+ f"{server_url}/pairing/start",
74
+ json={"serverUrl": server_url, "ttlSeconds": int(args.timeout)},
75
+ )
76
+ start_response.raise_for_status()
77
+ pairing = start_response.json()
78
+ pairing_id = pairing["pairingId"]
79
+ code = pairing["code"]
80
+
81
+ print(f"Pairing code: {code}")
82
+ print("Claim it from the web UI")
83
+ # print(
84
+ # "curl -s "
85
+ # f"{server_url}/pairing/claim "
86
+ # "-H 'content-type: application/json' "
87
+ # f"-d '{{\"code\":\"{code}\",\"name\":\"local-codex\",\"userId\":\"local\",\"serverUrl\":\"{server_url}\"}}'"
88
+ # )
89
+ print("Waiting for credentials...")
90
+
91
+ deadline = time.monotonic() + args.timeout
92
+ while time.monotonic() < deadline:
93
+ poll_response = await client.post(f"{server_url}/pairing/poll", json={"pairingId": pairing_id})
94
+ poll_response.raise_for_status()
95
+ payload = poll_response.json()
96
+ if payload["status"] == "claimed" and payload.get("config"):
97
+ config = ConnectorConfig.from_mapping(payload["config"])
98
+ path = config.save(args.config)
99
+ print(f"Saved connector config: {path}")
100
+ if args.no_start:
101
+ return
102
+ print("Starting connector...")
103
+ await BackendRpcClient(config).run_forever()
104
+ return
105
+ if payload["status"] in {"expired", "consumed"}:
106
+ raise RuntimeError(f"pairing ended with status: {payload['status']}")
107
+ await asyncio.sleep(args.poll_interval)
108
+
109
+ raise TimeoutError("pairing timed out")
110
+
111
+
112
+ def _configure(args: argparse.Namespace) -> None:
113
+ config = ConnectorConfig(
114
+ server_url=args.server_url.rstrip("/"),
115
+ connector_id=args.connector_id,
116
+ connector_token=args.connector_token,
117
+ )
118
+ path = config.save(args.config)
119
+ print(f"Saved connector config: {path}")
120
+
121
+
122
+ def _resolve_config(args: argparse.Namespace) -> ConnectorConfig:
123
+ server_url = args.server_url or os.environ.get("AGENT_SERVER_URL")
124
+ connector_id = args.connector_id or os.environ.get("AGENT_CONNECTOR_ID")
125
+ connector_token = args.connector_token or os.environ.get("AGENT_CONNECTOR_TOKEN")
126
+ if server_url and connector_id and connector_token:
127
+ return ConnectorConfig(
128
+ server_url=server_url.rstrip("/"),
129
+ connector_id=connector_id,
130
+ connector_token=connector_token,
131
+ )
132
+
133
+ config_path = Path(args.config)
134
+ if config_path.exists():
135
+ return ConnectorConfig.load(config_path)
136
+
137
+ missing = []
138
+ if not server_url:
139
+ missing.append("--server-url")
140
+ if not connector_id:
141
+ missing.append("--connector-id")
142
+ if not connector_token:
143
+ missing.append("--connector-token")
144
+ missing.append(f"or config file {config_path}")
145
+ raise SystemExit("missing connector credentials: " + ", ".join(missing))
146
+
147
+
148
+ if __name__ == "__main__":
149
+ main(sys.argv[1:])
@@ -0,0 +1,3 @@
1
+ from connector.codex.adapter import CodexAdapter
2
+
3
+ __all__ = ["CodexAdapter"]