agentibridge 0.2.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,3 @@
1
+ """AgentiBridge — Claude CLI transcript index and MCP tools."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,5 @@
1
+ """Enable ``python -m agentibridge`` execution."""
2
+
3
+ from agentibridge.server import main
4
+
5
+ main()
@@ -0,0 +1,327 @@
1
+ """Run Claude CLI directly via subprocess, or proxy via HTTP bridge.
2
+
3
+ Replaces the old completions.py module that called an external agenticore
4
+ /completions API. Now AgentiBridge is fully standalone — it shells out to
5
+ the ``claude`` CLI binary which must be on PATH (or set via CLAUDE_BINARY).
6
+
7
+ When ``CLAUDE_DISPATCH_URL`` is set, requests are proxied to a host-side
8
+ dispatch bridge (see :mod:`agentibridge.dispatch_bridge`) instead of
9
+ spawning ``claude`` locally. This is used when running inside Docker.
10
+
11
+ Usage:
12
+ from agentibridge.claude_runner import run_claude_sync
13
+
14
+ result = run_claude_sync("Summarize this code")
15
+ if result["success"]:
16
+ print(result["result"])
17
+
18
+ Env vars:
19
+ CLAUDE_BINARY — path to claude CLI (default: "claude")
20
+ CLAUDE_DISPATCH_MODEL — model for dispatch (default: "sonnet")
21
+ CLAUDE_DISPATCH_TIMEOUT — timeout in seconds (default: 300)
22
+ CLAUDE_DISPATCH_URL — bridge URL (empty = local mode)
23
+ DISPATCH_SECRET — shared secret for bridge auth
24
+ """
25
+
26
+ import asyncio
27
+ import json
28
+ import os
29
+ from dataclasses import asdict, dataclass
30
+ from typing import Any, Dict, Optional
31
+
32
+ from agentibridge.logging import log
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Configuration
37
+ # ---------------------------------------------------------------------------
38
+
39
+
40
+ def _claude_binary() -> str:
41
+ return os.environ.get("CLAUDE_BINARY", "claude")
42
+
43
+
44
+ def _default_model() -> str:
45
+ return os.environ.get("CLAUDE_DISPATCH_MODEL", "sonnet")
46
+
47
+
48
+ def _default_timeout() -> int:
49
+ return int(os.environ.get("CLAUDE_DISPATCH_TIMEOUT", "300"))
50
+
51
+
52
+ def _dispatch_url() -> str:
53
+ return os.environ.get("CLAUDE_DISPATCH_URL", "")
54
+
55
+
56
+ def _dispatch_secret() -> str:
57
+ return os.environ.get("DISPATCH_SECRET", "")
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Result dataclass
62
+ # ---------------------------------------------------------------------------
63
+
64
+
65
+ @dataclass
66
+ class ClaudeResult:
67
+ """Result from a Claude CLI invocation."""
68
+
69
+ success: bool
70
+ result: Optional[str] = None
71
+ session_id: Optional[str] = None
72
+ exit_code: Optional[int] = None
73
+ duration_ms: Optional[int] = None
74
+ timed_out: bool = False
75
+ error: Optional[str] = None
76
+
77
+ def to_dict(self) -> Dict[str, Any]:
78
+ return asdict(self)
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Output parser
83
+ # ---------------------------------------------------------------------------
84
+
85
+
86
+ def parse_claude_output(raw: str) -> Dict[str, Any]:
87
+ """Parse JSON output from ``claude --output-format json``.
88
+
89
+ The CLI emits a JSON object with fields like:
90
+ - result (str) — the final text answer
91
+ - session_id (str) — Claude session UUID
92
+ - cost_usd (float) — cost in USD
93
+ - duration_ms (int) — wall-clock time
94
+ - duration_api_ms (int) — API time
95
+ - is_error (bool)
96
+
97
+ Returns a flat dict; callers pick the keys they need.
98
+ """
99
+ try:
100
+ data = json.loads(raw)
101
+ except (json.JSONDecodeError, TypeError):
102
+ return {"result": raw, "parse_error": True}
103
+
104
+ return data
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # HTTP transport (container → host bridge)
109
+ # ---------------------------------------------------------------------------
110
+
111
+
112
+ async def _run_claude_http(
113
+ dispatch_url: str,
114
+ prompt: str,
115
+ model: str,
116
+ timeout: int,
117
+ output_format: str,
118
+ resume_session_id: Optional[str] = None,
119
+ ) -> ClaudeResult:
120
+ """Proxy a dispatch request to the host-side bridge via HTTP.
121
+
122
+ Args:
123
+ dispatch_url: Base URL of the dispatch bridge (e.g. http://host.docker.internal:8101).
124
+ prompt: The prompt/task text.
125
+ model: Model name.
126
+ timeout: Timeout in seconds for the Claude CLI execution.
127
+ output_format: CLI output format.
128
+
129
+ Returns:
130
+ ClaudeResult with parsed output.
131
+ """
132
+ import httpx
133
+
134
+ secret = _dispatch_secret()
135
+ url = f"{dispatch_url.rstrip('/')}/dispatch"
136
+ # Give the bridge time to timeout first, then add buffer for HTTP overhead
137
+ http_timeout = timeout + 30
138
+
139
+ log("claude_runner: HTTP dispatch", {"url": url, "model": model, "prompt_len": len(prompt)})
140
+
141
+ try:
142
+ async with httpx.AsyncClient(timeout=http_timeout) as client:
143
+ resp = await client.post(
144
+ url,
145
+ json={
146
+ "prompt": prompt,
147
+ "model": model,
148
+ "timeout": timeout,
149
+ "output_format": output_format,
150
+ "resume_session_id": resume_session_id or "",
151
+ },
152
+ headers={"X-Dispatch-Secret": secret},
153
+ )
154
+
155
+ if resp.status_code == 401:
156
+ return ClaudeResult(success=False, error="Dispatch bridge auth failed (401)")
157
+
158
+ if resp.status_code != 200:
159
+ return ClaudeResult(
160
+ success=False,
161
+ error=f"Dispatch bridge returned HTTP {resp.status_code}: {resp.text[:500]}",
162
+ )
163
+
164
+ data = resp.json()
165
+ return ClaudeResult(
166
+ success=data.get("success", False),
167
+ result=data.get("result"),
168
+ session_id=data.get("session_id"),
169
+ exit_code=data.get("exit_code"),
170
+ duration_ms=data.get("duration_ms"),
171
+ timed_out=data.get("timed_out", False),
172
+ error=data.get("error"),
173
+ )
174
+
175
+ except httpx.ConnectError as e:
176
+ msg = f"Cannot connect to dispatch bridge at {dispatch_url}: {e}"
177
+ log("claude_runner: bridge connect error", {"url": dispatch_url, "error": str(e)})
178
+ return ClaudeResult(success=False, error=msg)
179
+
180
+ except httpx.TimeoutException:
181
+ log("claude_runner: bridge timeout", {"url": dispatch_url, "timeout": http_timeout})
182
+ return ClaudeResult(success=False, timed_out=True, error=f"Dispatch bridge timed out after {http_timeout}s")
183
+
184
+ except Exception as e:
185
+ log("claude_runner: bridge unexpected error", {"error": str(e)})
186
+ return ClaudeResult(success=False, error=f"Dispatch bridge error: {e}")
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # Async runner
191
+ # ---------------------------------------------------------------------------
192
+
193
+
194
+ async def run_claude(
195
+ prompt: str,
196
+ model: Optional[str] = None,
197
+ timeout: Optional[int] = None,
198
+ cwd: Optional[str] = None,
199
+ output_format: str = "json",
200
+ resume_session_id: Optional[str] = None,
201
+ ) -> ClaudeResult:
202
+ """Run the ``claude`` CLI and return the parsed result.
203
+
204
+ If ``CLAUDE_DISPATCH_URL`` is set, proxies the request to the host-side
205
+ dispatch bridge via HTTP. Otherwise, runs the CLI as a local subprocess.
206
+
207
+ Args:
208
+ prompt: The prompt/task text.
209
+ model: Model name (default: CLAUDE_DISPATCH_MODEL or "sonnet").
210
+ timeout: Timeout in seconds (default: CLAUDE_DISPATCH_TIMEOUT or 300).
211
+ cwd: Working directory for the subprocess.
212
+ output_format: CLI output format (default: "json").
213
+
214
+ Returns:
215
+ ClaudeResult with parsed output.
216
+ """
217
+ model = model or _default_model()
218
+ timeout = timeout or _default_timeout()
219
+
220
+ # Route to HTTP bridge if configured
221
+ dispatch_url = _dispatch_url()
222
+ if dispatch_url:
223
+ return await _run_claude_http(dispatch_url, prompt, model, timeout, output_format, resume_session_id)
224
+
225
+ # Local subprocess mode
226
+ binary = _claude_binary()
227
+ if resume_session_id:
228
+ cmd = [
229
+ binary,
230
+ "--dangerously-skip-permissions",
231
+ "--model",
232
+ model,
233
+ "--output-format",
234
+ output_format,
235
+ "--resume",
236
+ resume_session_id,
237
+ "--print",
238
+ prompt,
239
+ ]
240
+ else:
241
+ cmd = [
242
+ binary,
243
+ "--dangerously-skip-permissions",
244
+ "--model",
245
+ model,
246
+ "--output-format",
247
+ output_format,
248
+ "-p",
249
+ prompt,
250
+ ]
251
+
252
+ log("claude_runner: starting", {"model": model, "prompt_len": len(prompt)})
253
+
254
+ try:
255
+ proc = await asyncio.create_subprocess_exec(
256
+ *cmd,
257
+ stdin=asyncio.subprocess.PIPE,
258
+ stdout=asyncio.subprocess.PIPE,
259
+ stderr=asyncio.subprocess.PIPE,
260
+ cwd=cwd,
261
+ )
262
+
263
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
264
+ stdout_text = stdout.decode("utf-8", errors="replace") if stdout else ""
265
+ stderr_text = stderr.decode("utf-8", errors="replace") if stderr else ""
266
+
267
+ if proc.returncode != 0:
268
+ log("claude_runner: non-zero exit", {"exit_code": proc.returncode, "stderr": stderr_text[:500]})
269
+ return ClaudeResult(
270
+ success=False,
271
+ exit_code=proc.returncode,
272
+ error=stderr_text[:2000] or f"Exit code {proc.returncode}",
273
+ )
274
+
275
+ parsed = parse_claude_output(stdout_text)
276
+
277
+ return ClaudeResult(
278
+ success=not parsed.get("is_error", False),
279
+ result=parsed.get("result", stdout_text),
280
+ session_id=parsed.get("session_id"),
281
+ duration_ms=parsed.get("duration_ms"),
282
+ exit_code=proc.returncode,
283
+ error=parsed.get("result") if parsed.get("is_error") else None,
284
+ )
285
+
286
+ except asyncio.TimeoutError:
287
+ log("claude_runner: timeout", {"timeout": timeout})
288
+ return ClaudeResult(success=False, timed_out=True, error=f"Timed out after {timeout}s")
289
+
290
+ except FileNotFoundError:
291
+ msg = f"Claude CLI binary not found: {binary}"
292
+ log("claude_runner: binary not found", {"binary": binary})
293
+ return ClaudeResult(success=False, error=msg)
294
+
295
+ except Exception as e:
296
+ log("claude_runner: unexpected error", {"error": str(e)})
297
+ return ClaudeResult(success=False, error=str(e))
298
+
299
+
300
+ # ---------------------------------------------------------------------------
301
+ # Sync wrapper
302
+ # ---------------------------------------------------------------------------
303
+
304
+
305
+ def run_claude_sync(prompt: str, **kwargs) -> ClaudeResult:
306
+ """Synchronous wrapper around :func:`run_claude`.
307
+
308
+ If called from within a running event loop (e.g. MCP server context),
309
+ runs the coroutine in a separate thread to avoid the
310
+ "Cannot run the event loop while another loop is running" error.
311
+ Prefer calling :func:`run_claude` directly with ``await`` when possible.
312
+ """
313
+ import concurrent.futures
314
+
315
+ try:
316
+ asyncio.get_running_loop()
317
+ # Already inside an event loop — run in a thread with its own loop
318
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
319
+ future = pool.submit(asyncio.run, run_claude(prompt, **kwargs))
320
+ return future.result()
321
+ except RuntimeError:
322
+ # No running loop — safe to create one
323
+ loop = asyncio.new_event_loop()
324
+ try:
325
+ return loop.run_until_complete(run_claude(prompt, **kwargs))
326
+ finally:
327
+ loop.close()