augment-sdk 0.1.1__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.
augment/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """Augment SDK - Python client for Augment CLI agent"""
2
+
3
+ __version__ = "0.1.1"
4
+
5
+ from .agent import Agent, Model, VerificationResult
6
+ from .exceptions import (
7
+ AugmentError,
8
+ AugmentCLIError,
9
+ AugmentJSONError,
10
+ AugmentNotFoundError,
11
+ AugmentParseError,
12
+ AugmentWorkspaceError,
13
+ AugmentVerificationError,
14
+ )
15
+ from .listener import AgentListener, LoggingAgentListener
16
+
17
+ __all__ = [
18
+ "Agent",
19
+ "Model",
20
+ "VerificationResult",
21
+ "AgentListener",
22
+ "LoggingAgentListener",
23
+ "AugmentError",
24
+ "AugmentCLIError",
25
+ "AugmentJSONError",
26
+ "AugmentNotFoundError",
27
+ "AugmentParseError",
28
+ "AugmentWorkspaceError",
29
+ "AugmentVerificationError",
30
+ ]
@@ -0,0 +1,11 @@
1
+ """
2
+ ACP (Agent Client Protocol) client for Augment CLI.
3
+
4
+ This module provides a synchronous Python client for communicating with
5
+ the Augment CLI agent via the Agent Client Protocol.
6
+ """
7
+
8
+ from augment.acp.client import ACPClient, AuggieACPClient, AgentEventListener
9
+ from augment.acp.claude_code_client import ClaudeCodeACPClient
10
+
11
+ __all__ = ["ACPClient", "AuggieACPClient", "ClaudeCodeACPClient", "AgentEventListener"]
@@ -0,0 +1,365 @@
1
+ """
2
+ ACP Client for Claude Code via Zed's ACP Adapter
3
+
4
+ This module provides a client for communicating with Claude Code through
5
+ the @zed-industries/claude-code-acp adapter, which wraps the Claude Code SDK
6
+ to speak the Agent Client Protocol.
7
+ """
8
+
9
+ import asyncio
10
+ import os
11
+ import shutil
12
+ import subprocess
13
+ from pathlib import Path
14
+ from queue import Empty, Queue
15
+ from threading import Thread
16
+ from typing import Optional
17
+
18
+ from acp import (
19
+ ClientSideConnection,
20
+ InitializeRequest,
21
+ NewSessionRequest,
22
+ PromptRequest,
23
+ PROTOCOL_VERSION,
24
+ spawn_agent_process,
25
+ text_block,
26
+ )
27
+
28
+ from augment.acp.client import ACPClient, AgentEventListener, _InternalACPClient
29
+
30
+
31
+ class ClaudeCodeACPClient(ACPClient):
32
+ """
33
+ Synchronous ACP client for Claude Code via Zed's adapter.
34
+
35
+ This client provides a simple interface for:
36
+ - Starting/stopping Claude Code agent
37
+ - Sending messages and getting responses
38
+ - Listening to agent events (messages, tool calls, etc.)
39
+ - Clearing session context
40
+
41
+ Example:
42
+ ```python
43
+ # Create a client with API key
44
+ client = ClaudeCodeACPClient(
45
+ api_key="...",
46
+ model="claude-3-5-sonnet-latest"
47
+ )
48
+
49
+ # Start the agent
50
+ client.start()
51
+
52
+ # Send a message
53
+ response = client.send_message("What is 2 + 2?")
54
+ print(response)
55
+
56
+ # Stop the agent
57
+ client.stop()
58
+ ```
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ api_key: Optional[str] = None,
64
+ listener: Optional[AgentEventListener] = None,
65
+ model: Optional[str] = None,
66
+ workspace_root: Optional[str] = None,
67
+ adapter_path: Optional[str] = None,
68
+ ):
69
+ """
70
+ Initialize the Claude Code ACP client.
71
+
72
+ Args:
73
+ api_key: Anthropic API key. If None, uses ANTHROPIC_API_KEY env var.
74
+ listener: Optional event listener to receive agent events.
75
+ model: AI model to use (e.g., "claude-3-5-sonnet-latest").
76
+ If None, uses Claude Code's default model.
77
+ workspace_root: Workspace root directory. If None, uses current directory.
78
+ adapter_path: Path to claude-code-acp executable. If None, uses 'npx @zed-industries/claude-code-acp'.
79
+
80
+ Raises:
81
+ ValueError: If API key is not provided and not in environment
82
+ """
83
+ self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY")
84
+ if not self.api_key:
85
+ raise ValueError(
86
+ "ANTHROPIC_API_KEY must be provided either as argument or environment variable"
87
+ )
88
+
89
+ self.listener = listener
90
+ self.model = model
91
+ self.workspace_root = workspace_root
92
+ self.adapter_path = adapter_path
93
+
94
+ self._client: Optional[_InternalACPClient] = None
95
+ self._conn: Optional[ClientSideConnection] = None
96
+ self._session_id: Optional[str] = None
97
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
98
+ self._thread: Optional[Thread] = None
99
+ self._context = None
100
+ self._ready_queue: Optional[Queue] = None
101
+
102
+ def start(self, timeout: float = 30.0) -> None:
103
+ """
104
+ Start the Claude Code agent process and establish ACP connection.
105
+
106
+ Args:
107
+ timeout: Maximum time to wait for the agent to start (seconds)
108
+
109
+ Raises:
110
+ RuntimeError: If the agent is already started
111
+ TimeoutError: If the agent fails to start within the timeout
112
+ Exception: If initialization fails
113
+ """
114
+ if self._thread is not None:
115
+ raise RuntimeError("Agent already started")
116
+
117
+ self._ready_queue = Queue()
118
+ self._thread = Thread(target=self._run_async_loop, daemon=True)
119
+ self._thread.start()
120
+
121
+ # Wait for initialization with timeout
122
+ try:
123
+ result = self._ready_queue.get(timeout=timeout)
124
+ if isinstance(result, Exception):
125
+ raise result
126
+ except Empty:
127
+ # Queue.get() timed out - no result received within timeout
128
+ raise TimeoutError(
129
+ f"Claude Code agent failed to start within {timeout} seconds. "
130
+ f"Make sure @zed-industries/claude-code-acp is installed: "
131
+ f"npm install -g @zed-industries/claude-code-acp"
132
+ )
133
+
134
+ def stop(self) -> None:
135
+ """Stop the Claude Code agent process and cleanup resources."""
136
+ if self._loop is not None:
137
+ asyncio.run_coroutine_threadsafe(self._async_stop(), self._loop)
138
+ self._loop.call_soon_threadsafe(self._loop.stop)
139
+ if self._thread is not None:
140
+ self._thread.join(timeout=2.0)
141
+
142
+ self._client = None
143
+ self._conn = None
144
+ self._session_id = None
145
+ self._loop = None
146
+ self._thread = None
147
+ self._context = None
148
+
149
+ def send_message(self, message: str, timeout: float = 30.0) -> str:
150
+ """
151
+ Send a message to Claude Code and get the response.
152
+
153
+ Args:
154
+ message: The message to send
155
+ timeout: Maximum time to wait for response (seconds)
156
+
157
+ Returns:
158
+ The agent's response as a string
159
+
160
+ Raises:
161
+ RuntimeError: If the agent is not started
162
+ TimeoutError: If the response takes too long
163
+ """
164
+ if self._loop is None or self._conn is None or self._client is None:
165
+ raise RuntimeError("Agent not started. Call start() first.")
166
+
167
+ # Reset the response and completion flag
168
+ self._client.last_response = ""
169
+ self._client.message_complete = False
170
+
171
+ # Schedule the async query
172
+ future = asyncio.run_coroutine_threadsafe(
173
+ self._async_send_message(message), self._loop
174
+ )
175
+
176
+ # Wait for completion
177
+ future.result(timeout=timeout)
178
+
179
+ # Wait for message_complete flag (with timeout)
180
+ import time
181
+
182
+ start_time = time.time()
183
+ while not self._client.message_complete:
184
+ if time.time() - start_time > 2.0: # Max 2 seconds to wait for completion
185
+ # If we don't get message_end event, just return what we have
186
+ break
187
+ time.sleep(0.05) # Small sleep to avoid busy waiting
188
+
189
+ return self._client.get_last_response()
190
+
191
+ def clear_context(self) -> None:
192
+ """
193
+ Clear the session context by restarting the agent.
194
+
195
+ This stops the current agent and starts a new one with a fresh session.
196
+ """
197
+ self.stop()
198
+ self.start()
199
+
200
+ @property
201
+ def session_id(self) -> Optional[str]:
202
+ """Get the current session ID."""
203
+ return self._session_id
204
+
205
+ @property
206
+ def is_running(self) -> bool:
207
+ """Check if the agent is currently running."""
208
+ return self._thread is not None and self._loop is not None
209
+
210
+ def _run_async_loop(self):
211
+ """Run the asyncio event loop in a background thread."""
212
+ try:
213
+ self._loop = asyncio.new_event_loop()
214
+ asyncio.set_event_loop(self._loop)
215
+ self._loop.run_until_complete(self._async_start())
216
+ self._ready_queue.put(True)
217
+ self._loop.run_forever()
218
+ except Exception as e:
219
+ self._ready_queue.put(e)
220
+
221
+ async def _async_start(self):
222
+ """Async initialization."""
223
+ self._client = _InternalACPClient(self.listener)
224
+
225
+ # Build command to spawn claude-code-acp
226
+ if self.adapter_path:
227
+ # Use explicit path if provided
228
+ cli_args = [self.adapter_path]
229
+ else:
230
+ # Use npx to run the package
231
+ cli_args = ["npx", "@zed-industries/claude-code-acp"]
232
+
233
+ # Check if npx is available
234
+ if not self.adapter_path and not shutil.which("npx"):
235
+ raise RuntimeError(
236
+ "npx not found. Please install Node.js or provide adapter_path explicitly."
237
+ )
238
+
239
+ # Set up environment variables
240
+ env = os.environ.copy()
241
+ env["ANTHROPIC_API_KEY"] = self.api_key
242
+
243
+ # Note: The adapter may use different env vars for model configuration
244
+ # We'll need to check the adapter's documentation for the exact variable name
245
+ if self.model:
246
+ # Try common patterns - the adapter will use what it supports
247
+ env["CLAUDE_CODE_MODEL"] = self.model
248
+ env["MODEL"] = self.model
249
+
250
+ # Spawn the agent process with environment variables
251
+ self._context = spawn_agent_process(
252
+ lambda _agent: self._client, *cli_args, env=env
253
+ )
254
+
255
+ # Start the process and get connection
256
+ conn_proc = await self._context.__aenter__()
257
+ self._conn, self._proc = conn_proc
258
+
259
+ # Create a task to monitor if the process exits early
260
+ async def wait_for_process_exit():
261
+ """Wait for the process to exit and raise an error if it does."""
262
+ await self._proc.wait()
263
+ stderr = ""
264
+ if self._proc.stderr:
265
+ try:
266
+ stderr_bytes = await asyncio.wait_for(
267
+ self._proc.stderr.read(), timeout=1.0
268
+ )
269
+ stderr = stderr_bytes.decode("utf-8", errors="replace")
270
+ except Exception:
271
+ pass
272
+ raise RuntimeError(
273
+ f"Claude Code agent process exited with code {self._proc.returncode}.\n"
274
+ f"Stderr: {stderr}"
275
+ )
276
+
277
+ # Check if process has already exited
278
+ if self._proc.returncode is not None:
279
+ # Process already exited
280
+ stderr = ""
281
+ if self._proc.stderr:
282
+ stderr_bytes = await self._proc.stderr.read()
283
+ stderr = stderr_bytes.decode("utf-8", errors="replace")
284
+ raise RuntimeError(
285
+ f"Claude Code agent process exited immediately with code {self._proc.returncode}.\n"
286
+ f"Stderr: {stderr}"
287
+ )
288
+
289
+ # Create process monitor task
290
+ monitor_task = asyncio.create_task(wait_for_process_exit())
291
+
292
+ try:
293
+ # Race between initialization and process exit
294
+ init_task = asyncio.create_task(
295
+ self._conn.initialize(
296
+ InitializeRequest(
297
+ protocolVersion=PROTOCOL_VERSION, clientCapabilities=None
298
+ )
299
+ )
300
+ )
301
+ done, pending = await asyncio.wait(
302
+ [init_task, monitor_task], return_when=asyncio.FIRST_COMPLETED
303
+ )
304
+
305
+ # If monitor task completed first, it means process exited
306
+ if monitor_task in done:
307
+ # Cancel the init task
308
+ init_task.cancel()
309
+ # Re-raise the exception from monitor_task
310
+ await monitor_task
311
+
312
+ # Otherwise, initialization succeeded
313
+ await init_task
314
+
315
+ # Use workspace_root as cwd if provided, otherwise use current directory
316
+ cwd = self.workspace_root if self.workspace_root else os.getcwd()
317
+
318
+ # Race between session creation and process exit
319
+ session_task = asyncio.create_task(
320
+ self._conn.newSession(NewSessionRequest(mcpServers=[], cwd=cwd))
321
+ )
322
+ done, pending = await asyncio.wait(
323
+ [session_task, monitor_task], return_when=asyncio.FIRST_COMPLETED
324
+ )
325
+
326
+ # If monitor task completed first, it means process exited
327
+ if monitor_task in done:
328
+ # Cancel the session task
329
+ session_task.cancel()
330
+ # Re-raise the exception from monitor_task
331
+ await monitor_task
332
+
333
+ # Otherwise, session creation succeeded
334
+ session = await session_task
335
+ self._session_id = session.sessionId
336
+
337
+ # Keep the monitor task running in the background
338
+ # (don't cancel it, it will keep monitoring the process)
339
+ except Exception:
340
+ # If anything fails, cancel the monitor task
341
+ monitor_task.cancel()
342
+ raise
343
+
344
+ async def _async_send_message(self, message: str):
345
+ """Async message sending."""
346
+ await self._conn.prompt(
347
+ PromptRequest(
348
+ sessionId=self._session_id,
349
+ prompt=[text_block(message)],
350
+ )
351
+ )
352
+
353
+ async def _async_stop(self):
354
+ """Async cleanup."""
355
+ if self._context is not None:
356
+ await self._context.__aexit__(None, None, None)
357
+
358
+ def __enter__(self):
359
+ """Context manager entry."""
360
+ self.start()
361
+ return self
362
+
363
+ def __exit__(self, exc_type, exc_val, exc_tb):
364
+ """Context manager exit."""
365
+ self.stop()