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/__init__.py +3 -0
- emdash_cli/client.py +556 -0
- emdash_cli/commands/__init__.py +37 -0
- emdash_cli/commands/agent.py +883 -0
- emdash_cli/commands/analyze.py +137 -0
- emdash_cli/commands/auth.py +121 -0
- emdash_cli/commands/db.py +95 -0
- emdash_cli/commands/embed.py +103 -0
- emdash_cli/commands/index.py +134 -0
- emdash_cli/commands/plan.py +77 -0
- emdash_cli/commands/projectmd.py +51 -0
- emdash_cli/commands/research.py +47 -0
- emdash_cli/commands/rules.py +93 -0
- emdash_cli/commands/search.py +56 -0
- emdash_cli/commands/server.py +117 -0
- emdash_cli/commands/spec.py +49 -0
- emdash_cli/commands/swarm.py +86 -0
- emdash_cli/commands/tasks.py +52 -0
- emdash_cli/commands/team.py +51 -0
- emdash_cli/main.py +104 -0
- emdash_cli/server_manager.py +231 -0
- emdash_cli/sse_renderer.py +442 -0
- emdash_cli-0.1.4.dist-info/METADATA +17 -0
- emdash_cli-0.1.4.dist-info/RECORD +26 -0
- emdash_cli-0.1.4.dist-info/WHEEL +4 -0
- emdash_cli-0.1.4.dist-info/entry_points.txt +5 -0
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
|