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 +15 -0
- emdash_cli/client.py +129 -0
- emdash_cli/clipboard.py +123 -0
- emdash_cli/commands/agent.py +526 -34
- emdash_cli/session_store.py +321 -0
- emdash_cli/sse_renderer.py +224 -119
- {emdash_cli-0.1.25.dist-info → emdash_cli-0.1.35.dist-info}/METADATA +4 -2
- {emdash_cli-0.1.25.dist-info → emdash_cli-0.1.35.dist-info}/RECORD +10 -8
- {emdash_cli-0.1.25.dist-info → emdash_cli-0.1.35.dist-info}/WHEEL +0 -0
- {emdash_cli-0.1.25.dist-info → emdash_cli-0.1.35.dist-info}/entry_points.txt +0 -0
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
|
|
emdash_cli/clipboard.py
ADDED
|
@@ -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
|