emdash-cli 0.1.25__py3-none-any.whl → 0.1.35__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.
emdash_cli/__init__.py CHANGED
@@ -1,6 +1,21 @@
1
1
  """EmDash CLI - Command-line interface for code intelligence."""
2
2
 
3
3
  from importlib.metadata import version, PackageNotFoundError
4
+ from pathlib import Path
5
+
6
+ # Load .env files early so env vars are available for server subprocess
7
+ try:
8
+ from dotenv import load_dotenv
9
+ # Try to find .env in current dir or parent dirs
10
+ current = Path.cwd()
11
+ for _ in range(5):
12
+ env_path = current / ".env"
13
+ if env_path.exists():
14
+ load_dotenv(env_path, override=True)
15
+ break
16
+ current = current.parent
17
+ except ImportError:
18
+ pass # dotenv not installed
4
19
 
5
20
  try:
6
21
  __version__ = version("emdash-cli")
emdash_cli/client.py CHANGED
@@ -52,6 +52,8 @@ class EmdashClient:
52
52
  session_id: Optional[str] = None,
53
53
  max_iterations: int = _get_max_iterations(),
54
54
  options: Optional[dict] = None,
55
+ images: Optional[list[dict]] = None,
56
+ history: Optional[list[dict]] = None,
55
57
  ) -> Iterator[str]:
56
58
  """Stream agent chat response via SSE.
57
59
 
@@ -61,6 +63,8 @@ class EmdashClient:
61
63
  session_id: Session ID for continuity (optional)
62
64
  max_iterations: Max agent iterations
63
65
  options: Additional options (mode, save, no_graph_tools, etc.)
66
+ images: List of images [{"data": base64_str, "format": "png"}]
67
+ history: Pre-loaded conversation history from saved session
64
68
 
65
69
  Yields:
66
70
  SSE lines from the response
@@ -87,6 +91,10 @@ class EmdashClient:
87
91
  payload["model"] = model
88
92
  if session_id:
89
93
  payload["session_id"] = session_id
94
+ if images:
95
+ payload["images"] = images
96
+ if history:
97
+ payload["history"] = history
90
98
 
91
99
  try:
92
100
  with self._client.stream(
@@ -105,17 +113,21 @@ class EmdashClient:
105
113
  self,
106
114
  session_id: str,
107
115
  message: str,
116
+ images: Optional[list[dict]] = None,
108
117
  ) -> Iterator[str]:
109
118
  """Continue an existing agent session.
110
119
 
111
120
  Args:
112
121
  session_id: Existing session ID
113
122
  message: Continuation message
123
+ images: List of images [{"data": base64_str, "format": "png"}]
114
124
 
115
125
  Yields:
116
126
  SSE lines from the response
117
127
  """
118
128
  payload = {"message": message}
129
+ if images:
130
+ payload["images"] = images
119
131
 
120
132
  try:
121
133
  with self._client.stream(
@@ -130,6 +142,123 @@ class EmdashClient:
130
142
  # Stream was closed early (interrupted)
131
143
  pass
132
144
 
145
+ def plan_approve_stream(self, session_id: str) -> Iterator[str]:
146
+ """Approve a pending plan and start implementation.
147
+
148
+ Args:
149
+ session_id: Session ID with pending plan
150
+
151
+ Yields:
152
+ SSE lines from the response
153
+ """
154
+ try:
155
+ with self._client.stream(
156
+ "POST",
157
+ f"{self.base_url}/api/agent/chat/{session_id}/plan/approve",
158
+ ) as response:
159
+ response.raise_for_status()
160
+ for line in response.iter_lines():
161
+ yield line
162
+ except GeneratorExit:
163
+ pass
164
+
165
+ def plan_reject_stream(self, session_id: str, feedback: str = "") -> Iterator[str]:
166
+ """Reject a pending plan with feedback.
167
+
168
+ Args:
169
+ session_id: Session ID with pending plan
170
+ feedback: Feedback explaining rejection
171
+
172
+ Yields:
173
+ SSE lines from the response
174
+ """
175
+ try:
176
+ with self._client.stream(
177
+ "POST",
178
+ f"{self.base_url}/api/agent/chat/{session_id}/plan/reject",
179
+ params={"feedback": feedback},
180
+ ) as response:
181
+ response.raise_for_status()
182
+ for line in response.iter_lines():
183
+ yield line
184
+ except GeneratorExit:
185
+ pass
186
+
187
+ def planmode_approve_stream(self, session_id: str) -> Iterator[str]:
188
+ """Approve entering plan mode.
189
+
190
+ Args:
191
+ session_id: Session ID requesting plan mode
192
+
193
+ Yields:
194
+ SSE lines from the response
195
+ """
196
+ try:
197
+ with self._client.stream(
198
+ "POST",
199
+ f"{self.base_url}/api/agent/chat/{session_id}/planmode/approve",
200
+ ) as response:
201
+ response.raise_for_status()
202
+ for line in response.iter_lines():
203
+ yield line
204
+ except GeneratorExit:
205
+ pass
206
+
207
+ def planmode_reject_stream(self, session_id: str, feedback: str = "") -> Iterator[str]:
208
+ """Reject entering plan mode.
209
+
210
+ Args:
211
+ session_id: Session ID requesting plan mode
212
+ feedback: Feedback explaining rejection
213
+
214
+ Yields:
215
+ SSE lines from the response
216
+ """
217
+ try:
218
+ with self._client.stream(
219
+ "POST",
220
+ f"{self.base_url}/api/agent/chat/{session_id}/planmode/reject",
221
+ params={"feedback": feedback},
222
+ ) as response:
223
+ response.raise_for_status()
224
+ for line in response.iter_lines():
225
+ yield line
226
+ except GeneratorExit:
227
+ pass
228
+
229
+ def clarification_answer_stream(self, session_id: str, answer: str) -> Iterator[str]:
230
+ """Answer a pending clarification question.
231
+
232
+ Args:
233
+ session_id: Session ID with pending clarification
234
+ answer: User's answer to the clarification question
235
+
236
+ Yields:
237
+ SSE lines from the response
238
+ """
239
+ try:
240
+ with self._client.stream(
241
+ "POST",
242
+ f"{self.base_url}/api/agent/chat/{session_id}/clarification/answer",
243
+ params={"answer": answer},
244
+ ) as response:
245
+ response.raise_for_status()
246
+ for line in response.iter_lines():
247
+ yield line
248
+ except GeneratorExit:
249
+ pass
250
+
251
+ def get(self, path: str) -> "httpx.Response":
252
+ """Make a GET request to the API.
253
+
254
+ Args:
255
+ path: API path (e.g., "/api/agent/sessions")
256
+
257
+ Returns:
258
+ HTTP response
259
+ """
260
+ return self._client.get(f"{self.base_url}{path}")
261
+
133
262
  def list_sessions(self) -> list[dict]:
134
263
  """List active agent sessions.
135
264
 
@@ -0,0 +1,123 @@
1
+ """Clipboard utilities for image handling."""
2
+
3
+ import base64
4
+ import io
5
+ from typing import Optional, Tuple
6
+
7
+
8
+ def get_clipboard_image() -> Optional[Tuple[str, str]]:
9
+ """Get image from clipboard if available.
10
+
11
+ Returns:
12
+ Tuple of (base64_data, format) if image found, None otherwise.
13
+ """
14
+ try:
15
+ from PIL import ImageGrab, Image
16
+
17
+ # Try to grab image from clipboard
18
+ image = ImageGrab.grabclipboard()
19
+
20
+ if image is None:
21
+ return None
22
+
23
+ # Handle list of file paths (Windows)
24
+ if isinstance(image, list):
25
+ # It's a list of file paths
26
+ if image and isinstance(image[0], str):
27
+ try:
28
+ image = Image.open(image[0])
29
+ except Exception:
30
+ return None
31
+ else:
32
+ return None
33
+
34
+ # Convert to PNG bytes
35
+ if isinstance(image, Image.Image):
36
+ buffer = io.BytesIO()
37
+ # Convert to RGB if necessary (for RGBA images)
38
+ if image.mode in ('RGBA', 'LA') or (image.mode == 'P' and 'transparency' in image.info):
39
+ # Keep as PNG to preserve transparency
40
+ image.save(buffer, format='PNG')
41
+ img_format = 'png'
42
+ else:
43
+ # Convert to JPEG for smaller size
44
+ if image.mode != 'RGB':
45
+ image = image.convert('RGB')
46
+ image.save(buffer, format='JPEG', quality=85)
47
+ img_format = 'jpeg'
48
+
49
+ buffer.seek(0)
50
+ base64_data = base64.b64encode(buffer.read()).decode('utf-8')
51
+ return base64_data, img_format
52
+
53
+ except ImportError:
54
+ # PIL not available
55
+ return None
56
+ except Exception:
57
+ # Any other error (no clipboard access, etc.)
58
+ return None
59
+
60
+ return None
61
+
62
+
63
+ def get_image_from_path(path: str) -> Optional[Tuple[str, str]]:
64
+ """Load image from file path.
65
+
66
+ Args:
67
+ path: Path to image file
68
+
69
+ Returns:
70
+ Tuple of (base64_data, format) if successful, None otherwise.
71
+ """
72
+ try:
73
+ from PIL import Image
74
+
75
+ image = Image.open(path)
76
+ buffer = io.BytesIO()
77
+
78
+ # Determine format from file extension
79
+ ext = path.lower().split('.')[-1]
80
+ if ext in ('jpg', 'jpeg'):
81
+ if image.mode != 'RGB':
82
+ image = image.convert('RGB')
83
+ image.save(buffer, format='JPEG', quality=85)
84
+ img_format = 'jpeg'
85
+ elif ext == 'png':
86
+ image.save(buffer, format='PNG')
87
+ img_format = 'png'
88
+ elif ext == 'gif':
89
+ image.save(buffer, format='GIF')
90
+ img_format = 'gif'
91
+ elif ext == 'webp':
92
+ image.save(buffer, format='WEBP')
93
+ img_format = 'webp'
94
+ else:
95
+ # Default to PNG
96
+ image.save(buffer, format='PNG')
97
+ img_format = 'png'
98
+
99
+ buffer.seek(0)
100
+ base64_data = base64.b64encode(buffer.read()).decode('utf-8')
101
+ return base64_data, img_format
102
+
103
+ except Exception:
104
+ return None
105
+
106
+
107
+ def get_image_dimensions(base64_data: str) -> Optional[Tuple[int, int]]:
108
+ """Get dimensions of base64-encoded image.
109
+
110
+ Args:
111
+ base64_data: Base64-encoded image data
112
+
113
+ Returns:
114
+ Tuple of (width, height) if successful, None otherwise.
115
+ """
116
+ try:
117
+ from PIL import Image
118
+
119
+ image_bytes = base64.b64decode(base64_data)
120
+ image = Image.open(io.BytesIO(image_bytes))
121
+ return image.size
122
+ except Exception:
123
+ return None