emdash-cli 0.1.18__tar.gz

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.
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: emdash-cli
3
+ Version: 0.1.18
4
+ Summary: EmDash CLI - Command-line interface for code intelligence
5
+ Author: Em Dash Team
6
+ Requires-Python: >=3.10,<4.0
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Dist: click (>=8.1.7,<9.0.0)
14
+ Requires-Dist: emdash-core (>=0.1.18)
15
+ Requires-Dist: httpx (>=0.25.0)
16
+ Requires-Dist: prompt_toolkit (>=3.0.43,<4.0.0)
17
+ Requires-Dist: rich (>=13.7.0)
@@ -0,0 +1,8 @@
1
+ """EmDash CLI - Command-line interface for code intelligence."""
2
+
3
+ from importlib.metadata import version, PackageNotFoundError
4
+
5
+ try:
6
+ __version__ = version("emdash-cli")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0-dev"
@@ -0,0 +1,570 @@
1
+ """HTTP client for emdash-core API."""
2
+
3
+ import os
4
+ from typing import Any, Iterator, Optional
5
+
6
+ import httpx
7
+
8
+
9
+ def _get_max_iterations() -> int:
10
+ """Get max iterations from env var with default."""
11
+ return int(os.getenv("EMDASH_MAX_ITERATIONS", "100"))
12
+
13
+
14
+ class EmdashClient:
15
+ """HTTP client for interacting with emdash-core API.
16
+
17
+ This client handles:
18
+ - Regular JSON API calls
19
+ - SSE streaming for agent chat
20
+ - Health checks
21
+ """
22
+
23
+ def __init__(self, base_url: str):
24
+ """Initialize the client.
25
+
26
+ Args:
27
+ base_url: Base URL of emdash-core server
28
+ """
29
+ self.base_url = base_url.rstrip("/")
30
+ self._client = httpx.Client(timeout=None) # No timeout for streaming
31
+
32
+ def health(self) -> dict:
33
+ """Check server health.
34
+
35
+ Returns:
36
+ Health status dict
37
+
38
+ Raises:
39
+ httpx.HTTPError: If request fails
40
+ """
41
+ response = self._client.get(
42
+ f"{self.base_url}/api/health",
43
+ timeout=5.0,
44
+ )
45
+ response.raise_for_status()
46
+ return response.json()
47
+
48
+ def agent_chat_stream(
49
+ self,
50
+ message: str,
51
+ model: Optional[str] = None,
52
+ session_id: Optional[str] = None,
53
+ max_iterations: int = _get_max_iterations(),
54
+ options: Optional[dict] = None,
55
+ ) -> Iterator[str]:
56
+ """Stream agent chat response via SSE.
57
+
58
+ Args:
59
+ message: User message/task
60
+ model: Model to use (optional)
61
+ session_id: Session ID for continuity (optional)
62
+ max_iterations: Max agent iterations
63
+ options: Additional options (mode, save, no_graph_tools, etc.)
64
+
65
+ Yields:
66
+ SSE lines from the response
67
+
68
+ Raises:
69
+ httpx.HTTPError: If request fails
70
+ """
71
+ # Build options with defaults
72
+ request_options = {
73
+ "max_iterations": max_iterations,
74
+ "verbose": True,
75
+ }
76
+
77
+ # Merge additional options
78
+ if options:
79
+ request_options.update(options)
80
+
81
+ payload = {
82
+ "message": message,
83
+ "options": request_options,
84
+ }
85
+
86
+ if model:
87
+ payload["model"] = model
88
+ if session_id:
89
+ payload["session_id"] = session_id
90
+
91
+ try:
92
+ with self._client.stream(
93
+ "POST",
94
+ f"{self.base_url}/api/agent/chat",
95
+ json=payload,
96
+ ) as response:
97
+ response.raise_for_status()
98
+ for line in response.iter_lines():
99
+ yield line
100
+ except GeneratorExit:
101
+ # Stream was closed early (interrupted)
102
+ pass
103
+
104
+ def agent_continue_stream(
105
+ self,
106
+ session_id: str,
107
+ message: str,
108
+ ) -> Iterator[str]:
109
+ """Continue an existing agent session.
110
+
111
+ Args:
112
+ session_id: Existing session ID
113
+ message: Continuation message
114
+
115
+ Yields:
116
+ SSE lines from the response
117
+ """
118
+ payload = {"message": message}
119
+
120
+ try:
121
+ with self._client.stream(
122
+ "POST",
123
+ f"{self.base_url}/api/agent/chat/{session_id}/continue",
124
+ json=payload,
125
+ ) as response:
126
+ response.raise_for_status()
127
+ for line in response.iter_lines():
128
+ yield line
129
+ except GeneratorExit:
130
+ # Stream was closed early (interrupted)
131
+ pass
132
+
133
+ def list_sessions(self) -> list[dict]:
134
+ """List active agent sessions.
135
+
136
+ Returns:
137
+ List of session info dicts
138
+ """
139
+ response = self._client.get(f"{self.base_url}/api/agent/sessions")
140
+ response.raise_for_status()
141
+ return response.json().get("sessions", [])
142
+
143
+ def delete_session(self, session_id: str) -> bool:
144
+ """Delete an agent session.
145
+
146
+ Args:
147
+ session_id: Session to delete
148
+
149
+ Returns:
150
+ True if deleted
151
+ """
152
+ response = self._client.delete(
153
+ f"{self.base_url}/api/agent/sessions/{session_id}"
154
+ )
155
+ return response.status_code == 200
156
+
157
+ def search(
158
+ self,
159
+ query: str,
160
+ search_type: str = "semantic",
161
+ limit: int = 20,
162
+ ) -> dict:
163
+ """Search the codebase.
164
+
165
+ Args:
166
+ query: Search query
167
+ search_type: Type of search (semantic, text, grep)
168
+ limit: Maximum results
169
+
170
+ Returns:
171
+ Search response dict
172
+ """
173
+ response = self._client.post(
174
+ f"{self.base_url}/api/query/search",
175
+ json={
176
+ "query": query,
177
+ "type": search_type,
178
+ "filters": {"limit": limit},
179
+ },
180
+ )
181
+ response.raise_for_status()
182
+ return response.json()
183
+
184
+ def index_status(self, repo_path: str) -> dict:
185
+ """Get indexing status for a repository.
186
+
187
+ Args:
188
+ repo_path: Path to repository
189
+
190
+ Returns:
191
+ Index status dict
192
+ """
193
+ response = self._client.get(
194
+ f"{self.base_url}/api/index/status",
195
+ params={"repo_path": repo_path}
196
+ )
197
+ response.raise_for_status()
198
+ return response.json()
199
+
200
+ def index_start_stream(
201
+ self,
202
+ repo_path: str,
203
+ incremental: bool = False,
204
+ ) -> Iterator[str]:
205
+ """Start indexing with SSE streaming progress.
206
+
207
+ Args:
208
+ repo_path: Path to repository
209
+ incremental: Only index changed files
210
+
211
+ Yields:
212
+ SSE lines from the response
213
+ """
214
+ payload = {
215
+ "repo_path": repo_path,
216
+ "options": {"incremental": incremental},
217
+ }
218
+
219
+ with self._client.stream(
220
+ "POST",
221
+ f"{self.base_url}/api/index/start",
222
+ json=payload,
223
+ ) as response:
224
+ response.raise_for_status()
225
+ for line in response.iter_lines():
226
+ yield line
227
+
228
+ # ==================== Auth ====================
229
+
230
+ def auth_login(self) -> dict:
231
+ """Start GitHub OAuth device flow.
232
+
233
+ Returns:
234
+ Dict with user_code, verification_uri, expires_in, interval
235
+ """
236
+ response = self._client.post(f"{self.base_url}/api/auth/login")
237
+ response.raise_for_status()
238
+ return response.json()
239
+
240
+ def auth_poll(self, user_code: str) -> dict:
241
+ """Poll for login completion.
242
+
243
+ Args:
244
+ user_code: The user code from auth_login
245
+
246
+ Returns:
247
+ Dict with status (pending/success/expired/error), username, error
248
+ """
249
+ response = self._client.post(
250
+ f"{self.base_url}/api/auth/login/poll/{user_code}"
251
+ )
252
+ response.raise_for_status()
253
+ return response.json()
254
+
255
+ def auth_logout(self) -> dict:
256
+ """Sign out by removing stored credentials."""
257
+ response = self._client.post(f"{self.base_url}/api/auth/logout")
258
+ response.raise_for_status()
259
+ return response.json()
260
+
261
+ def auth_status(self) -> dict:
262
+ """Get current authentication status.
263
+
264
+ Returns:
265
+ Dict with authenticated, username, scope
266
+ """
267
+ response = self._client.get(f"{self.base_url}/api/auth/status")
268
+ response.raise_for_status()
269
+ return response.json()
270
+
271
+ # ==================== Database ====================
272
+
273
+ def db_init(self) -> dict:
274
+ """Initialize the database schema."""
275
+ response = self._client.post(f"{self.base_url}/api/db/init")
276
+ response.raise_for_status()
277
+ return response.json()
278
+
279
+ def db_clear(self, confirm: bool = True) -> dict:
280
+ """Clear all data from the database."""
281
+ response = self._client.post(
282
+ f"{self.base_url}/api/db/clear",
283
+ params={"confirm": confirm},
284
+ )
285
+ response.raise_for_status()
286
+ return response.json()
287
+
288
+ def db_stats(self) -> dict:
289
+ """Get database statistics."""
290
+ response = self._client.get(f"{self.base_url}/api/db/stats")
291
+ response.raise_for_status()
292
+ return response.json()
293
+
294
+ def db_test(self) -> dict:
295
+ """Test database connection."""
296
+ response = self._client.get(f"{self.base_url}/api/db/test")
297
+ response.raise_for_status()
298
+ return response.json()
299
+
300
+ # ==================== Analytics ====================
301
+
302
+ def analyze_pagerank(self, top: int = 20, damping: float = 0.85) -> dict:
303
+ """Compute PageRank scores."""
304
+ response = self._client.post(
305
+ f"{self.base_url}/api/analyze/pagerank",
306
+ json={"top": top, "damping": damping},
307
+ )
308
+ response.raise_for_status()
309
+ return response.json()
310
+
311
+ def analyze_communities(
312
+ self,
313
+ resolution: float = 1.0,
314
+ min_size: int = 3,
315
+ top: int = 20,
316
+ ) -> dict:
317
+ """Detect code communities."""
318
+ response = self._client.post(
319
+ f"{self.base_url}/api/analyze/communities",
320
+ json={"resolution": resolution, "min_size": min_size, "top": top},
321
+ )
322
+ response.raise_for_status()
323
+ return response.json()
324
+
325
+ def analyze_areas(
326
+ self,
327
+ depth: int = 2,
328
+ days: int = 30,
329
+ top: int = 20,
330
+ sort: str = "focus",
331
+ files: bool = False,
332
+ ) -> dict:
333
+ """Get importance metrics by directory or file."""
334
+ response = self._client.post(
335
+ f"{self.base_url}/api/analyze/areas",
336
+ json={
337
+ "depth": depth,
338
+ "days": days,
339
+ "top": top,
340
+ "sort": sort,
341
+ "files": files,
342
+ },
343
+ )
344
+ response.raise_for_status()
345
+ return response.json()
346
+
347
+ # ==================== Embeddings ====================
348
+
349
+ def embed_status(self) -> dict:
350
+ """Get embedding coverage statistics."""
351
+ response = self._client.get(f"{self.base_url}/api/embed/status")
352
+ response.raise_for_status()
353
+ return response.json()
354
+
355
+ def embed_models(self) -> list:
356
+ """List available embedding models."""
357
+ response = self._client.get(f"{self.base_url}/api/embed/models")
358
+ response.raise_for_status()
359
+ return response.json().get("models", [])
360
+
361
+ def embed_index(
362
+ self,
363
+ include_prs: bool = True,
364
+ include_functions: bool = True,
365
+ include_classes: bool = True,
366
+ reindex: bool = False,
367
+ ) -> dict:
368
+ """Generate embeddings for graph entities."""
369
+ response = self._client.post(
370
+ f"{self.base_url}/api/embed/index",
371
+ json={
372
+ "include_prs": include_prs,
373
+ "include_functions": include_functions,
374
+ "include_classes": include_classes,
375
+ "reindex": reindex,
376
+ },
377
+ )
378
+ response.raise_for_status()
379
+ return response.json()
380
+
381
+ # ==================== Rules/Templates ====================
382
+
383
+ def rules_list(self) -> list:
384
+ """List all templates."""
385
+ response = self._client.get(f"{self.base_url}/api/rules/list")
386
+ response.raise_for_status()
387
+ return response.json().get("templates", [])
388
+
389
+ def rules_get(self, template_name: str) -> dict:
390
+ """Get a template's content."""
391
+ response = self._client.get(f"{self.base_url}/api/rules/{template_name}")
392
+ response.raise_for_status()
393
+ return response.json()
394
+
395
+ def rules_init(self, global_templates: bool = False, force: bool = False) -> dict:
396
+ """Initialize custom templates."""
397
+ response = self._client.post(
398
+ f"{self.base_url}/api/rules/init",
399
+ params={"global_templates": global_templates, "force": force},
400
+ )
401
+ response.raise_for_status()
402
+ return response.json()
403
+
404
+ # ==================== Project MD ====================
405
+
406
+ def projectmd_generate_stream(
407
+ self,
408
+ output: str = "PROJECT.md",
409
+ save: bool = True,
410
+ model: Optional[str] = None,
411
+ ) -> Iterator[str]:
412
+ """Generate PROJECT.md with SSE streaming.
413
+
414
+ Args:
415
+ output: Output file path
416
+ save: Whether to save to file
417
+ model: Model to use
418
+
419
+ Yields:
420
+ SSE lines from the response
421
+ """
422
+ payload = {"output": output, "save": save}
423
+ if model:
424
+ payload["model"] = model
425
+
426
+ with self._client.stream(
427
+ "POST",
428
+ f"{self.base_url}/api/projectmd/generate",
429
+ json=payload,
430
+ ) as response:
431
+ response.raise_for_status()
432
+ for line in response.iter_lines():
433
+ yield line
434
+
435
+ # ==================== Spec & Tasks ====================
436
+
437
+ def spec_generate_stream(
438
+ self,
439
+ feature: str,
440
+ model: Optional[str] = None,
441
+ save: bool = False,
442
+ ) -> Iterator[str]:
443
+ """Generate feature specification with SSE streaming."""
444
+ payload = {"feature": feature, "save": save}
445
+ if model:
446
+ payload["model"] = model
447
+
448
+ with self._client.stream(
449
+ "POST",
450
+ f"{self.base_url}/api/spec/generate",
451
+ json=payload,
452
+ ) as response:
453
+ response.raise_for_status()
454
+ for line in response.iter_lines():
455
+ yield line
456
+
457
+ def tasks_generate_stream(
458
+ self,
459
+ spec_name: Optional[str] = None,
460
+ model: Optional[str] = None,
461
+ save: bool = False,
462
+ ) -> Iterator[str]:
463
+ """Generate implementation tasks with SSE streaming."""
464
+ payload = {"save": save}
465
+ if spec_name:
466
+ payload["spec_name"] = spec_name
467
+ if model:
468
+ payload["model"] = model
469
+
470
+ with self._client.stream(
471
+ "POST",
472
+ f"{self.base_url}/api/tasks/generate",
473
+ json=payload,
474
+ ) as response:
475
+ response.raise_for_status()
476
+ for line in response.iter_lines():
477
+ yield line
478
+
479
+ def plan_context(self, description: str, similar_prs: int = 5) -> dict:
480
+ """Get planning context for a feature."""
481
+ response = self._client.post(
482
+ f"{self.base_url}/api/plan/context",
483
+ json={"description": description, "similar_prs": similar_prs},
484
+ )
485
+ response.raise_for_status()
486
+ return response.json()
487
+
488
+ # ==================== Research ====================
489
+
490
+ def research_stream(
491
+ self,
492
+ goal: str,
493
+ max_iterations: int = _get_max_iterations(),
494
+ budget: int = 50,
495
+ model: Optional[str] = None,
496
+ ) -> Iterator[str]:
497
+ """Deep research with SSE streaming."""
498
+ payload = {
499
+ "goal": goal,
500
+ "max_iterations": max_iterations,
501
+ "budget": budget,
502
+ }
503
+ if model:
504
+ payload["model"] = model
505
+
506
+ with self._client.stream(
507
+ "POST",
508
+ f"{self.base_url}/api/research/run",
509
+ json=payload,
510
+ ) as response:
511
+ response.raise_for_status()
512
+ for line in response.iter_lines():
513
+ yield line
514
+
515
+ # ==================== Team ====================
516
+
517
+ def team_focus(
518
+ self,
519
+ days: int = 14,
520
+ model: Optional[str] = None,
521
+ ) -> dict:
522
+ """Get team's recent focus analysis."""
523
+ payload = {"days": days}
524
+ if model:
525
+ payload["model"] = model
526
+
527
+ response = self._client.post(
528
+ f"{self.base_url}/api/team/focus",
529
+ json=payload,
530
+ )
531
+ response.raise_for_status()
532
+ return response.json()
533
+
534
+ # ==================== Swarm ====================
535
+
536
+ def swarm_run_stream(
537
+ self,
538
+ tasks: list[str],
539
+ model: Optional[str] = None,
540
+ auto_merge: bool = False,
541
+ ) -> Iterator[str]:
542
+ """Run multi-agent swarm with SSE streaming."""
543
+ payload = {"tasks": tasks, "auto_merge": auto_merge}
544
+ if model:
545
+ payload["model"] = model
546
+
547
+ with self._client.stream(
548
+ "POST",
549
+ f"{self.base_url}/api/swarm/run",
550
+ json=payload,
551
+ ) as response:
552
+ response.raise_for_status()
553
+ for line in response.iter_lines():
554
+ yield line
555
+
556
+ def swarm_status(self) -> dict:
557
+ """Get swarm execution status."""
558
+ response = self._client.get(f"{self.base_url}/api/swarm/status")
559
+ response.raise_for_status()
560
+ return response.json()
561
+
562
+ def close(self) -> None:
563
+ """Close the HTTP client."""
564
+ self._client.close()
565
+
566
+ def __enter__(self) -> "EmdashClient":
567
+ return self
568
+
569
+ def __exit__(self, *args: Any) -> None:
570
+ self.close()
@@ -0,0 +1,37 @@
1
+ """CLI command implementations."""
2
+
3
+ from .agent import agent
4
+ from .db import db
5
+ from .auth import auth
6
+ from .analyze import analyze
7
+ from .embed import embed
8
+ from .index import index
9
+ from .plan import plan
10
+ from .rules import rules
11
+ from .search import search
12
+ from .server import server
13
+ from .team import team
14
+ from .swarm import swarm
15
+ from .projectmd import projectmd
16
+ from .research import research
17
+ from .spec import spec
18
+ from .tasks import tasks
19
+
20
+ __all__ = [
21
+ "agent",
22
+ "db",
23
+ "auth",
24
+ "analyze",
25
+ "embed",
26
+ "index",
27
+ "plan",
28
+ "rules",
29
+ "search",
30
+ "server",
31
+ "team",
32
+ "swarm",
33
+ "projectmd",
34
+ "research",
35
+ "spec",
36
+ "tasks",
37
+ ]