emdash-cli 0.1.35__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 (30) hide show
  1. emdash_cli/client.py +35 -0
  2. emdash_cli/clipboard.py +30 -61
  3. emdash_cli/commands/agent/__init__.py +14 -0
  4. emdash_cli/commands/agent/cli.py +100 -0
  5. emdash_cli/commands/agent/constants.py +53 -0
  6. emdash_cli/commands/agent/file_utils.py +178 -0
  7. emdash_cli/commands/agent/handlers/__init__.py +41 -0
  8. emdash_cli/commands/agent/handlers/agents.py +421 -0
  9. emdash_cli/commands/agent/handlers/auth.py +69 -0
  10. emdash_cli/commands/agent/handlers/doctor.py +319 -0
  11. emdash_cli/commands/agent/handlers/hooks.py +121 -0
  12. emdash_cli/commands/agent/handlers/mcp.py +183 -0
  13. emdash_cli/commands/agent/handlers/misc.py +200 -0
  14. emdash_cli/commands/agent/handlers/rules.py +394 -0
  15. emdash_cli/commands/agent/handlers/sessions.py +168 -0
  16. emdash_cli/commands/agent/handlers/setup.py +582 -0
  17. emdash_cli/commands/agent/handlers/skills.py +440 -0
  18. emdash_cli/commands/agent/handlers/todos.py +98 -0
  19. emdash_cli/commands/agent/handlers/verify.py +648 -0
  20. emdash_cli/commands/agent/interactive.py +657 -0
  21. emdash_cli/commands/agent/menus.py +728 -0
  22. emdash_cli/commands/agent.py +7 -1321
  23. emdash_cli/commands/server.py +99 -40
  24. emdash_cli/server_manager.py +70 -10
  25. emdash_cli/sse_renderer.py +36 -5
  26. {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.46.dist-info}/METADATA +2 -4
  27. emdash_cli-0.1.46.dist-info/RECORD +49 -0
  28. emdash_cli-0.1.35.dist-info/RECORD +0 -30
  29. {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.46.dist-info}/WHEEL +0 -0
  30. {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.46.dist-info}/entry_points.txt +0 -0
emdash_cli/client.py CHANGED
@@ -688,6 +688,41 @@ class EmdashClient:
688
688
  response.raise_for_status()
689
689
  return response.json()
690
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
+
691
726
  def close(self) -> None:
692
727
  """Close the HTTP client."""
693
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
+ }
@@ -0,0 +1,178 @@
1
+ """File utilities for the agent CLI.
2
+
3
+ Handles @file reference expansion and fuzzy file finding.
4
+ """
5
+
6
+ import fnmatch
7
+ import os
8
+ import re
9
+ from pathlib import Path
10
+
11
+ from rich.console import Console
12
+
13
+ console = Console()
14
+
15
+
16
+ def fuzzy_find_files(query: str, limit: int = 10) -> list[Path]:
17
+ """Find files matching a fuzzy query.
18
+
19
+ Args:
20
+ query: File name or partial path to search for
21
+ limit: Maximum number of results
22
+
23
+ Returns:
24
+ List of matching file paths
25
+ """
26
+ cwd = Path.cwd()
27
+ matches = []
28
+
29
+ # Common directories to skip
30
+ skip_dirs = {'.git', 'node_modules', '__pycache__', '.venv', 'venv', 'dist', 'build', '.emdash'}
31
+
32
+ # Walk the directory tree (more control than glob for skipping dirs)
33
+ for root, dirs, files in os.walk(cwd):
34
+ # Skip unwanted directories
35
+ dirs[:] = [d for d in dirs if d not in skip_dirs and not d.startswith('.')]
36
+
37
+ rel_root = Path(root).relative_to(cwd)
38
+
39
+ for file in files:
40
+ # Skip hidden files
41
+ if file.startswith('.'):
42
+ continue
43
+
44
+ rel_path = rel_root / file if str(rel_root) != '.' else Path(file)
45
+ full_path = cwd / rel_path
46
+
47
+ # Check if query matches (case-insensitive)
48
+ path_str = str(rel_path).lower()
49
+ query_lower = query.lower()
50
+
51
+ if query_lower in path_str or fnmatch.fnmatch(path_str, f"*{query_lower}*"):
52
+ matches.append(full_path)
53
+
54
+ if len(matches) >= limit:
55
+ return matches
56
+
57
+ return matches
58
+
59
+
60
+ def select_file_interactive(matches: list[Path], query: str) -> Path | None:
61
+ """Show interactive file selection menu.
62
+
63
+ Args:
64
+ matches: List of matching file paths
65
+ query: Original query string
66
+
67
+ Returns:
68
+ Selected file path or None if cancelled
69
+ """
70
+ if not matches:
71
+ return None
72
+
73
+ if len(matches) == 1:
74
+ return matches[0]
75
+
76
+ cwd = Path.cwd()
77
+
78
+ # Print numbered list
79
+ console.print(f"\n[bold cyan]Select file for @{query}:[/bold cyan]\n")
80
+
81
+ for i, path in enumerate(matches):
82
+ try:
83
+ rel_path = path.relative_to(cwd)
84
+ except ValueError:
85
+ rel_path = path
86
+ console.print(f" [bold]{i + 1}[/bold]) {rel_path}")
87
+
88
+ console.print(f"\n[dim]Enter number (1-{len(matches)}) or press Enter to cancel:[/dim]")
89
+
90
+ try:
91
+ from prompt_toolkit import PromptSession
92
+ selection_session = PromptSession()
93
+ choice = selection_session.prompt("").strip()
94
+ if choice.isdigit():
95
+ idx = int(choice) - 1
96
+ if 0 <= idx < len(matches):
97
+ return matches[idx]
98
+ except (KeyboardInterrupt, EOFError):
99
+ pass
100
+
101
+ return None
102
+
103
+
104
+ def expand_file_references(message: str) -> tuple[str, list[str]]:
105
+ """Expand @file references in a message to include file contents.
106
+
107
+ Supports:
108
+ - @file.txt - exact path (relative or absolute)
109
+ - @utils - fuzzy search for files containing "utils"
110
+ - @~/path/file.txt - home directory paths
111
+
112
+ Shows interactive selection if multiple files match.
113
+
114
+ Args:
115
+ message: User message potentially containing @file references
116
+
117
+ Returns:
118
+ Tuple of (expanded_message, list_of_included_files)
119
+ """
120
+ # Pattern to match @word (not followed by space immediately, at least 2 chars)
121
+ pattern = r'@([^\s@]{2,})'
122
+
123
+ files_included = []
124
+ file_contents = []
125
+ replacements = {} # Store replacements to apply after iteration
126
+
127
+ # Find all @references
128
+ for match in re.finditer(pattern, message):
129
+ file_query = match.group(1)
130
+ original = match.group(0)
131
+
132
+ # Skip if already processed
133
+ if original in replacements:
134
+ continue
135
+
136
+ # Expand ~ to home directory
137
+ if file_query.startswith("~"):
138
+ file_query_expanded = os.path.expanduser(file_query)
139
+ else:
140
+ file_query_expanded = file_query
141
+
142
+ # Check if it's an exact path first
143
+ path = Path(file_query_expanded)
144
+ if not path.is_absolute():
145
+ path = Path.cwd() / path
146
+
147
+ resolved_path = None
148
+
149
+ if path.exists() and path.is_file():
150
+ # Exact match
151
+ resolved_path = path
152
+ else:
153
+ # Fuzzy search
154
+ matches = fuzzy_find_files(file_query)
155
+ if matches:
156
+ resolved_path = select_file_interactive(matches, file_query)
157
+
158
+ if resolved_path:
159
+ try:
160
+ content = resolved_path.read_text()
161
+ files_included.append(str(resolved_path))
162
+ file_contents.append(f"\n\n**File: {resolved_path.name}**\n```\n{content}\n```")
163
+ replacements[original] = "" # Remove the @reference
164
+ except Exception:
165
+ pass # Can't read file, leave as-is
166
+
167
+ # Apply replacements
168
+ expanded_message = message
169
+ for original, replacement in replacements.items():
170
+ expanded_message = expanded_message.replace(original, replacement)
171
+
172
+ expanded_message = expanded_message.strip()
173
+
174
+ # Append file contents to the message
175
+ if file_contents:
176
+ expanded_message = expanded_message + "\n" + "\n".join(file_contents)
177
+
178
+ return expanded_message, files_included
@@ -0,0 +1,41 @@
1
+ """Slash command handlers for the agent CLI."""
2
+
3
+ from .agents import handle_agents
4
+ from .sessions import handle_session
5
+ from .todos import handle_todos, handle_todo_add
6
+ from .hooks import handle_hooks
7
+ from .rules import handle_rules
8
+ from .skills import handle_skills
9
+ from .mcp import handle_mcp
10
+ from .auth import handle_auth
11
+ from .doctor import handle_doctor
12
+ from .verify import handle_verify, handle_verify_loop
13
+ from .setup import handle_setup
14
+ from .misc import (
15
+ handle_status,
16
+ handle_pr,
17
+ handle_projectmd,
18
+ handle_research,
19
+ handle_context,
20
+ )
21
+
22
+ __all__ = [
23
+ "handle_agents",
24
+ "handle_session",
25
+ "handle_todos",
26
+ "handle_todo_add",
27
+ "handle_hooks",
28
+ "handle_rules",
29
+ "handle_skills",
30
+ "handle_mcp",
31
+ "handle_auth",
32
+ "handle_doctor",
33
+ "handle_verify",
34
+ "handle_verify_loop",
35
+ "handle_setup",
36
+ "handle_status",
37
+ "handle_pr",
38
+ "handle_projectmd",
39
+ "handle_research",
40
+ "handle_context",
41
+ ]