emdash-cli 0.1.30__py3-none-any.whl → 0.1.46__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.
Files changed (32) hide show
  1. emdash_cli/__init__.py +15 -0
  2. emdash_cli/client.py +156 -0
  3. emdash_cli/clipboard.py +30 -61
  4. emdash_cli/commands/agent/__init__.py +14 -0
  5. emdash_cli/commands/agent/cli.py +100 -0
  6. emdash_cli/commands/agent/constants.py +53 -0
  7. emdash_cli/commands/agent/file_utils.py +178 -0
  8. emdash_cli/commands/agent/handlers/__init__.py +41 -0
  9. emdash_cli/commands/agent/handlers/agents.py +421 -0
  10. emdash_cli/commands/agent/handlers/auth.py +69 -0
  11. emdash_cli/commands/agent/handlers/doctor.py +319 -0
  12. emdash_cli/commands/agent/handlers/hooks.py +121 -0
  13. emdash_cli/commands/agent/handlers/mcp.py +183 -0
  14. emdash_cli/commands/agent/handlers/misc.py +200 -0
  15. emdash_cli/commands/agent/handlers/rules.py +394 -0
  16. emdash_cli/commands/agent/handlers/sessions.py +168 -0
  17. emdash_cli/commands/agent/handlers/setup.py +582 -0
  18. emdash_cli/commands/agent/handlers/skills.py +440 -0
  19. emdash_cli/commands/agent/handlers/todos.py +98 -0
  20. emdash_cli/commands/agent/handlers/verify.py +648 -0
  21. emdash_cli/commands/agent/interactive.py +657 -0
  22. emdash_cli/commands/agent/menus.py +728 -0
  23. emdash_cli/commands/agent.py +7 -856
  24. emdash_cli/commands/server.py +99 -40
  25. emdash_cli/server_manager.py +70 -10
  26. emdash_cli/session_store.py +321 -0
  27. emdash_cli/sse_renderer.py +256 -110
  28. {emdash_cli-0.1.30.dist-info → emdash_cli-0.1.46.dist-info}/METADATA +2 -4
  29. emdash_cli-0.1.46.dist-info/RECORD +49 -0
  30. emdash_cli-0.1.30.dist-info/RECORD +0 -29
  31. {emdash_cli-0.1.30.dist-info → emdash_cli-0.1.46.dist-info}/WHEEL +0 -0
  32. {emdash_cli-0.1.30.dist-info → emdash_cli-0.1.46.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
@@ -53,6 +53,7 @@ class EmdashClient:
53
53
  max_iterations: int = _get_max_iterations(),
54
54
  options: Optional[dict] = None,
55
55
  images: Optional[list[dict]] = None,
56
+ history: Optional[list[dict]] = None,
56
57
  ) -> Iterator[str]:
57
58
  """Stream agent chat response via SSE.
58
59
 
@@ -63,6 +64,7 @@ class EmdashClient:
63
64
  max_iterations: Max agent iterations
64
65
  options: Additional options (mode, save, no_graph_tools, etc.)
65
66
  images: List of images [{"data": base64_str, "format": "png"}]
67
+ history: Pre-loaded conversation history from saved session
66
68
 
67
69
  Yields:
68
70
  SSE lines from the response
@@ -91,6 +93,8 @@ class EmdashClient:
91
93
  payload["session_id"] = session_id
92
94
  if images:
93
95
  payload["images"] = images
96
+ if history:
97
+ payload["history"] = history
94
98
 
95
99
  try:
96
100
  with self._client.stream(
@@ -138,6 +142,123 @@ class EmdashClient:
138
142
  # Stream was closed early (interrupted)
139
143
  pass
140
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
+
141
262
  def list_sessions(self) -> list[dict]:
142
263
  """List active agent sessions.
143
264
 
@@ -567,6 +688,41 @@ class EmdashClient:
567
688
  response.raise_for_status()
568
689
  return response.json()
569
690
 
691
+ # ==================== Todos ====================
692
+
693
+ def get_todos(self, session_id: str) -> dict:
694
+ """Get the current todo list for a session.
695
+
696
+ Args:
697
+ session_id: Session ID
698
+
699
+ Returns:
700
+ Dict with todos list and summary
701
+ """
702
+ response = self._client.get(
703
+ f"{self.base_url}/api/agent/chat/{session_id}/todos"
704
+ )
705
+ response.raise_for_status()
706
+ return response.json()
707
+
708
+ def add_todo(self, session_id: str, title: str, description: str = "") -> dict:
709
+ """Add a new todo item to the agent's task list.
710
+
711
+ Args:
712
+ session_id: Session ID
713
+ title: Todo title
714
+ description: Optional description
715
+
716
+ Returns:
717
+ Dict with created task info
718
+ """
719
+ response = self._client.post(
720
+ f"{self.base_url}/api/agent/chat/{session_id}/todos",
721
+ params={"title": title, "description": description},
722
+ )
723
+ response.raise_for_status()
724
+ return response.json()
725
+
570
726
  def close(self) -> None:
571
727
  """Close the HTTP client."""
572
728
  self._client.close()
emdash_cli/clipboard.py CHANGED
@@ -1,9 +1,18 @@
1
- """Clipboard utilities for image handling."""
1
+ """Clipboard utilities for image handling.
2
+
3
+ Uses platform-native clipboard access (no Pillow dependency).
4
+ """
2
5
 
3
6
  import base64
4
- import io
5
7
  from typing import Optional, Tuple
6
8
 
9
+ from emdash_core.utils.image import (
10
+ read_clipboard_image,
11
+ is_clipboard_image_available,
12
+ get_image_info,
13
+ ClipboardImageError,
14
+ )
15
+
7
16
 
8
17
  def get_clipboard_image() -> Optional[Tuple[str, str]]:
9
18
  """Get image from clipboard if available.
@@ -12,57 +21,28 @@ def get_clipboard_image() -> Optional[Tuple[str, str]]:
12
21
  Tuple of (base64_data, format) if image found, None otherwise.
13
22
  """
14
23
  try:
15
- from PIL import ImageGrab, Image
16
-
17
- # Try to grab image from clipboard
18
- image = ImageGrab.grabclipboard()
24
+ if not is_clipboard_image_available():
25
+ return None
19
26
 
20
- if image is None:
27
+ image_data = read_clipboard_image()
28
+ if image_data is None:
21
29
  return None
22
30
 
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
31
+ # Encode to base64
32
+ base64_data = base64.b64encode(image_data).decode('utf-8')
33
+ return base64_data, 'png'
34
+
35
+ except ClipboardImageError:
55
36
  return None
56
37
  except Exception:
57
- # Any other error (no clipboard access, etc.)
58
38
  return None
59
39
 
60
- return None
61
-
62
40
 
63
41
  def get_image_from_path(path: str) -> Optional[Tuple[str, str]]:
64
42
  """Load image from file path.
65
43
 
44
+ Only PNG files are fully supported. Other formats will be read as raw bytes.
45
+
66
46
  Args:
67
47
  path: Path to image file
68
48
 
@@ -70,34 +50,23 @@ def get_image_from_path(path: str) -> Optional[Tuple[str, str]]:
70
50
  Tuple of (base64_data, format) if successful, None otherwise.
71
51
  """
72
52
  try:
73
- from PIL import Image
74
-
75
- image = Image.open(path)
76
- buffer = io.BytesIO()
53
+ with open(path, 'rb') as f:
54
+ image_data = f.read()
77
55
 
78
56
  # Determine format from file extension
79
57
  ext = path.lower().split('.')[-1]
80
58
  if ext in ('jpg', 'jpeg'):
81
- if image.mode != 'RGB':
82
- image = image.convert('RGB')
83
- image.save(buffer, format='JPEG', quality=85)
84
59
  img_format = 'jpeg'
85
60
  elif ext == 'png':
86
- image.save(buffer, format='PNG')
87
61
  img_format = 'png'
88
62
  elif ext == 'gif':
89
- image.save(buffer, format='GIF')
90
63
  img_format = 'gif'
91
64
  elif ext == 'webp':
92
- image.save(buffer, format='WEBP')
93
65
  img_format = 'webp'
94
66
  else:
95
- # Default to PNG
96
- image.save(buffer, format='PNG')
97
67
  img_format = 'png'
98
68
 
99
- buffer.seek(0)
100
- base64_data = base64.b64encode(buffer.read()).decode('utf-8')
69
+ base64_data = base64.b64encode(image_data).decode('utf-8')
101
70
  return base64_data, img_format
102
71
 
103
72
  except Exception:
@@ -105,7 +74,7 @@ def get_image_from_path(path: str) -> Optional[Tuple[str, str]]:
105
74
 
106
75
 
107
76
  def get_image_dimensions(base64_data: str) -> Optional[Tuple[int, int]]:
108
- """Get dimensions of base64-encoded image.
77
+ """Get dimensions of base64-encoded PNG image.
109
78
 
110
79
  Args:
111
80
  base64_data: Base64-encoded image data
@@ -114,10 +83,10 @@ def get_image_dimensions(base64_data: str) -> Optional[Tuple[int, int]]:
114
83
  Tuple of (width, height) if successful, None otherwise.
115
84
  """
116
85
  try:
117
- from PIL import Image
118
-
119
86
  image_bytes = base64.b64decode(base64_data)
120
- image = Image.open(io.BytesIO(image_bytes))
121
- return image.size
87
+ info = get_image_info(image_bytes)
88
+ if info.get("width") and info.get("height"):
89
+ return info["width"], info["height"]
90
+ return None
122
91
  except Exception:
123
92
  return None
@@ -0,0 +1,14 @@
1
+ """Agent CLI commands package.
2
+
3
+ This package contains the refactored agent CLI code, split into:
4
+ - cli.py: Click command definitions
5
+ - constants.py: Enums and constants
6
+ - file_utils.py: File reference expansion utilities
7
+ - menus.py: Interactive prompt_toolkit menus
8
+ - interactive.py: Main REPL loop
9
+ - handlers/: Slash command handlers
10
+ """
11
+
12
+ from .cli import agent, agent_code
13
+
14
+ __all__ = ["agent", "agent_code"]
@@ -0,0 +1,100 @@
1
+ """Click CLI commands for the agent."""
2
+
3
+ import os
4
+
5
+ import click
6
+ from rich.console import Console
7
+
8
+ from ...client import EmdashClient
9
+ from ...server_manager import get_server_manager
10
+ from ...sse_renderer import SSERenderer
11
+ from .interactive import run_interactive, run_single_task
12
+
13
+ console = Console()
14
+
15
+
16
+ @click.group()
17
+ def agent():
18
+ """AI agent commands."""
19
+ pass
20
+
21
+
22
+ @agent.command("code")
23
+ @click.argument("task", required=False)
24
+ @click.option("--model", "-m", default=None, help="Model to use")
25
+ @click.option("--mode", type=click.Choice(["plan", "code"]), default="code",
26
+ help="Starting mode")
27
+ @click.option("--quiet", "-q", is_flag=True, help="Less verbose output")
28
+ @click.option("--max-iterations", default=int(os.getenv("EMDASH_MAX_ITERATIONS", "100")), help="Max agent iterations")
29
+ @click.option("--no-graph-tools", is_flag=True, help="Skip graph exploration tools")
30
+ @click.option("--save", is_flag=True, help="Save specs to specs/<feature>/")
31
+ def agent_code(
32
+ task: str | None,
33
+ model: str | None,
34
+ mode: str,
35
+ quiet: bool,
36
+ max_iterations: int,
37
+ no_graph_tools: bool,
38
+ save: bool,
39
+ ):
40
+ """Start the coding agent.
41
+
42
+ With TASK: Run single task and exit
43
+ Without TASK: Start interactive REPL mode
44
+
45
+ MODES:
46
+ plan - Explore codebase and create plans (read-only)
47
+ code - Execute code changes (default)
48
+
49
+ SLASH COMMANDS (in interactive mode):
50
+ /plan - Switch to plan mode
51
+ /code - Switch to code mode
52
+ /help - Show available commands
53
+ /reset - Reset session
54
+
55
+ Examples:
56
+ emdash # Interactive code mode
57
+ emdash agent code # Same as above
58
+ emdash agent code --mode plan # Start in plan mode
59
+ emdash agent code "Fix the login bug" # Single task
60
+ """
61
+ # Get server URL (starts server if needed)
62
+ server = get_server_manager()
63
+ base_url = server.get_server_url()
64
+
65
+ client = EmdashClient(base_url)
66
+ renderer = SSERenderer(console=console, verbose=not quiet)
67
+
68
+ options = {
69
+ "mode": mode,
70
+ "no_graph_tools": no_graph_tools,
71
+ "save": save,
72
+ }
73
+
74
+ if task:
75
+ # Single task mode
76
+ run_single_task(client, renderer, task, model, max_iterations, options)
77
+ else:
78
+ # Interactive REPL mode
79
+ run_interactive(client, renderer, model, max_iterations, options)
80
+
81
+
82
+ @agent.command("sessions")
83
+ def list_sessions():
84
+ """List active agent sessions."""
85
+ server = get_server_manager()
86
+ base_url = server.get_server_url()
87
+
88
+ client = EmdashClient(base_url)
89
+ sessions = client.list_sessions()
90
+
91
+ if not sessions:
92
+ console.print("[dim]No active sessions[/dim]")
93
+ return
94
+
95
+ for s in sessions:
96
+ console.print(
97
+ f" {s['session_id'][:8]}... "
98
+ f"[dim]({s.get('model', 'unknown')}, "
99
+ f"{s.get('message_count', 0)} messages)[/dim]"
100
+ )
@@ -0,0 +1,53 @@
1
+ """Constants and enums for the agent CLI."""
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class AgentMode(Enum):
7
+ """Agent operation modes."""
8
+ PLAN = "plan"
9
+ CODE = "code"
10
+
11
+
12
+ # Slash commands available in interactive mode
13
+ SLASH_COMMANDS = {
14
+ # Mode switching
15
+ "/plan": "Switch to plan mode (explore codebase, create plans)",
16
+ "/code": "Switch to code mode (execute file changes)",
17
+ "/mode": "Show current mode",
18
+ # Generation commands
19
+ "/pr [url]": "Review a pull request",
20
+ "/projectmd": "Generate PROJECT.md for the codebase",
21
+ "/research [goal]": "Deep research on a topic",
22
+ # Status commands
23
+ "/status": "Show index and PROJECT.md status",
24
+ "/agents": "Manage agents (interactive menu, or /agents [create|show|edit|delete] <name>)",
25
+ # Todo management
26
+ "/todos": "Show current agent todo list",
27
+ "/todo-add [title]": "Add a todo item for the agent (e.g., /todo-add Fix tests)",
28
+ # Session management
29
+ "/session": "Save, load, or list sessions (e.g., /session save my-task)",
30
+ "/spec": "Show current specification",
31
+ "/reset": "Reset session state",
32
+ # Hooks
33
+ "/hooks": "Manage hooks (list, add, remove, toggle)",
34
+ # Rules
35
+ "/rules": "Manage rules (list, add, delete)",
36
+ # Skills
37
+ "/skills": "Manage skills (list, show, add, delete)",
38
+ # MCP
39
+ "/mcp": "Manage global MCP servers (list, edit)",
40
+ # Auth
41
+ "/auth": "GitHub authentication (login, logout, status)",
42
+ # Context
43
+ "/context": "Show current context frame (tokens, reranked items)",
44
+ # Diagnostics
45
+ "/doctor": "Check Python environment and diagnose issues",
46
+ # Verification
47
+ "/verify": "Run verification checks on current work",
48
+ "/verify-loop [task]": "Run task in loop until verifications pass",
49
+ # Setup wizard
50
+ "/setup": "Setup wizard for rules, agents, skills, and verifiers",
51
+ "/help": "Show available commands",
52
+ "/quit": "Exit the agent",
53
+ }