emdash-cli 0.1.4__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/main.py ADDED
@@ -0,0 +1,104 @@
1
+ """Main CLI entry point for emdash-cli."""
2
+
3
+ import click
4
+
5
+ from .commands import (
6
+ agent,
7
+ db,
8
+ auth,
9
+ analyze,
10
+ embed,
11
+ index,
12
+ plan,
13
+ rules,
14
+ search,
15
+ server,
16
+ team,
17
+ swarm,
18
+ projectmd,
19
+ research,
20
+ spec,
21
+ tasks,
22
+ )
23
+
24
+
25
+ @click.group()
26
+ @click.version_option()
27
+ def cli():
28
+ """EmDash - The 'Senior Engineer' Context Engine.
29
+
30
+ A graph-based coding intelligence system powered by AI.
31
+ """
32
+ pass
33
+
34
+
35
+ # Register command groups
36
+ cli.add_command(agent)
37
+ cli.add_command(db)
38
+ cli.add_command(auth)
39
+ cli.add_command(analyze)
40
+ cli.add_command(embed)
41
+ cli.add_command(index)
42
+ cli.add_command(plan)
43
+ cli.add_command(rules)
44
+ cli.add_command(server)
45
+ cli.add_command(team)
46
+ cli.add_command(swarm)
47
+
48
+ # Register standalone commands
49
+ cli.add_command(search)
50
+ cli.add_command(projectmd)
51
+ cli.add_command(research)
52
+ cli.add_command(spec)
53
+ cli.add_command(tasks)
54
+
55
+ # Add killall as top-level alias for server killall
56
+ from .commands.server import server_killall
57
+ cli.add_command(server_killall, name="killall")
58
+
59
+
60
+ # Direct entry point for `em` command - wraps agent_code with click
61
+ @click.command()
62
+ @click.argument("task", required=False)
63
+ @click.option("--model", "-m", default=None, help="Model to use")
64
+ @click.option("--mode", type=click.Choice(["plan", "tasks", "code"]), default="code",
65
+ help="Starting mode")
66
+ @click.option("--quiet", "-q", is_flag=True, help="Less verbose output")
67
+ @click.option("--max-iterations", default=20, help="Max agent iterations")
68
+ @click.option("--no-graph-tools", is_flag=True, help="Skip graph exploration tools")
69
+ @click.option("--save", is_flag=True, help="Save specs to specs/<feature>/")
70
+ def start_coding_agent(
71
+ task: str | None,
72
+ model: str | None,
73
+ mode: str,
74
+ quiet: bool,
75
+ max_iterations: int,
76
+ no_graph_tools: bool,
77
+ save: bool,
78
+ ):
79
+ """EmDash Coding Agent - AI-powered code assistant.
80
+
81
+ Start interactive mode or run a single task.
82
+
83
+ Examples:
84
+ em # Interactive mode
85
+ em "Fix the login bug" # Single task
86
+ em --mode plan # Start in plan mode
87
+ """
88
+ # Import and call agent_code directly
89
+ from .commands.agent import agent_code as _agent_code
90
+ ctx = click.Context(_agent_code)
91
+ ctx.invoke(
92
+ _agent_code,
93
+ task=task,
94
+ model=model,
95
+ mode=mode,
96
+ quiet=quiet,
97
+ max_iterations=max_iterations,
98
+ no_graph_tools=no_graph_tools,
99
+ save=save,
100
+ )
101
+
102
+
103
+ if __name__ == "__main__":
104
+ cli()
@@ -0,0 +1,231 @@
1
+ """Server lifecycle management for emdash-core."""
2
+
3
+ import atexit
4
+ import os
5
+ import signal
6
+ import socket
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ import httpx
14
+
15
+
16
+ class ServerManager:
17
+ """Manages FastAPI server lifecycle for CLI.
18
+
19
+ The ServerManager handles:
20
+ - Discovering running servers via port file
21
+ - Starting new servers when needed
22
+ - Health checking servers
23
+ - Graceful shutdown on CLI exit
24
+ """
25
+
26
+ DEFAULT_PORT = 8765
27
+ PORT_FILE = Path.home() / ".emdash" / "server.port"
28
+ PID_FILE = Path.home() / ".emdash" / "server.pid"
29
+ STARTUP_TIMEOUT = 30.0 # seconds
30
+ HEALTH_TIMEOUT = 2.0 # seconds
31
+
32
+ def __init__(self, repo_root: Optional[Path] = None):
33
+ """Initialize the server manager.
34
+
35
+ Args:
36
+ repo_root: Repository root path (for server to use)
37
+ """
38
+ self.repo_root = repo_root or self._detect_repo_root()
39
+ self.process: Optional[subprocess.Popen] = None
40
+ self.port: Optional[int] = None
41
+ self._started_by_us = False
42
+
43
+ def get_server_url(self) -> str:
44
+ """Get URL of running server, starting one if needed.
45
+
46
+ Returns:
47
+ Base URL of the running server (e.g., "http://localhost:8765")
48
+
49
+ Raises:
50
+ RuntimeError: If server fails to start
51
+ """
52
+ # Check if server already running
53
+ if self.PORT_FILE.exists():
54
+ try:
55
+ port = int(self.PORT_FILE.read_text().strip())
56
+ if self._check_health(port):
57
+ self.port = port
58
+ return f"http://localhost:{port}"
59
+ except (ValueError, IOError):
60
+ pass
61
+
62
+ # Start new server
63
+ self.port = self._find_free_port()
64
+ self._spawn_server()
65
+ return f"http://localhost:{self.port}"
66
+
67
+ def ensure_server(self) -> str:
68
+ """Ensure server is running and return URL.
69
+
70
+ Alias for get_server_url() for clearer intent.
71
+ """
72
+ return self.get_server_url()
73
+
74
+ def shutdown(self) -> None:
75
+ """Shutdown the server if we started it."""
76
+ if self._started_by_us and self.process:
77
+ try:
78
+ self.process.terminate()
79
+ self.process.wait(timeout=5)
80
+ except subprocess.TimeoutExpired:
81
+ self.process.kill()
82
+ finally:
83
+ self._cleanup_files()
84
+ self.process = None
85
+
86
+ def _detect_repo_root(self) -> Path:
87
+ """Detect repository root from current directory."""
88
+ try:
89
+ result = subprocess.run(
90
+ ["git", "rev-parse", "--show-toplevel"],
91
+ capture_output=True,
92
+ text=True,
93
+ check=True,
94
+ )
95
+ return Path(result.stdout.strip())
96
+ except subprocess.CalledProcessError:
97
+ return Path.cwd()
98
+
99
+ def _find_free_port(self) -> int:
100
+ """Find an available port."""
101
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
102
+ s.bind(("", 0))
103
+ s.listen(1)
104
+ return s.getsockname()[1]
105
+
106
+ def _spawn_server(self) -> None:
107
+ """Spawn FastAPI server as subprocess."""
108
+ # Find emdash-core module
109
+ core_module = self._find_core_module()
110
+
111
+ cmd = [
112
+ sys.executable,
113
+ "-m", "emdash_core.server",
114
+ "--port", str(self.port),
115
+ "--host", "127.0.0.1",
116
+ ]
117
+
118
+ if self.repo_root:
119
+ cmd.extend(["--repo-root", str(self.repo_root)])
120
+
121
+ # Set environment to include core package
122
+ env = os.environ.copy()
123
+ if core_module:
124
+ python_path = env.get("PYTHONPATH", "")
125
+ env["PYTHONPATH"] = f"{core_module}:{python_path}" if python_path else str(core_module)
126
+
127
+ self.process = subprocess.Popen(
128
+ cmd,
129
+ stdout=subprocess.PIPE,
130
+ stderr=subprocess.PIPE,
131
+ env=env,
132
+ )
133
+
134
+ self._started_by_us = True
135
+
136
+ # Register cleanup for normal exit
137
+ atexit.register(self.shutdown)
138
+
139
+ # Register signal handlers for Ctrl+C and termination
140
+ self._register_signal_handlers()
141
+
142
+ # Wait for server ready
143
+ self._wait_for_ready()
144
+
145
+ def _register_signal_handlers(self) -> None:
146
+ """Register signal handlers for graceful shutdown."""
147
+ def signal_handler(_signum, _frame):
148
+ self.shutdown()
149
+ sys.exit(0)
150
+
151
+ # Handle SIGINT (Ctrl+C) and SIGTERM
152
+ signal.signal(signal.SIGINT, signal_handler)
153
+ signal.signal(signal.SIGTERM, signal_handler)
154
+
155
+ def _find_core_module(self) -> Optional[Path]:
156
+ """Find the emdash-core package directory."""
157
+ # Check relative to this file (for development)
158
+ cli_dir = Path(__file__).parent.parent
159
+ core_dir = cli_dir.parent / "core"
160
+ if (core_dir / "emdash_core").exists():
161
+ return core_dir
162
+ return None
163
+
164
+ def _check_health(self, port: int) -> bool:
165
+ """Check if server is healthy.
166
+
167
+ Args:
168
+ port: Port to check
169
+
170
+ Returns:
171
+ True if server responds to health check
172
+ """
173
+ try:
174
+ response = httpx.get(
175
+ f"http://localhost:{port}/api/health",
176
+ timeout=self.HEALTH_TIMEOUT,
177
+ )
178
+ return response.status_code == 200
179
+ except (httpx.RequestError, httpx.TimeoutException):
180
+ return False
181
+
182
+ def _wait_for_ready(self) -> None:
183
+ """Wait for server to become ready.
184
+
185
+ Raises:
186
+ RuntimeError: If server fails to start within timeout
187
+ """
188
+ assert self.port is not None, "Port must be set before waiting for ready"
189
+ start = time.time()
190
+ while time.time() - start < self.STARTUP_TIMEOUT:
191
+ if self._check_health(self.port):
192
+ return
193
+
194
+ # Check if process died
195
+ if self.process and self.process.poll() is not None:
196
+ stderr = self.process.stderr.read().decode() if self.process.stderr else ""
197
+ raise RuntimeError(f"Server process died: {stderr}")
198
+
199
+ time.sleep(0.1)
200
+
201
+ raise RuntimeError(
202
+ f"Server failed to start within {self.STARTUP_TIMEOUT}s"
203
+ )
204
+
205
+ def _cleanup_files(self) -> None:
206
+ """Clean up port and PID files."""
207
+ for file in [self.PORT_FILE, self.PID_FILE]:
208
+ try:
209
+ if file.exists():
210
+ file.unlink()
211
+ except IOError:
212
+ pass
213
+
214
+
215
+ # Global singleton for CLI commands
216
+ _server_manager: Optional[ServerManager] = None
217
+
218
+
219
+ def get_server_manager(repo_root: Optional[Path] = None) -> ServerManager:
220
+ """Get or create the global server manager.
221
+
222
+ Args:
223
+ repo_root: Repository root (only used on first call)
224
+
225
+ Returns:
226
+ The server manager instance
227
+ """
228
+ global _server_manager
229
+ if _server_manager is None:
230
+ _server_manager = ServerManager(repo_root)
231
+ return _server_manager