emdash-cli 0.1.35__py3-none-any.whl → 0.1.67__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 (50) hide show
  1. emdash_cli/client.py +41 -22
  2. emdash_cli/clipboard.py +30 -61
  3. emdash_cli/commands/__init__.py +2 -2
  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 +63 -0
  7. emdash_cli/commands/agent/file_utils.py +178 -0
  8. emdash_cli/commands/agent/handlers/__init__.py +51 -0
  9. emdash_cli/commands/agent/handlers/agents.py +449 -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/index.py +183 -0
  14. emdash_cli/commands/agent/handlers/mcp.py +183 -0
  15. emdash_cli/commands/agent/handlers/misc.py +319 -0
  16. emdash_cli/commands/agent/handlers/registry.py +72 -0
  17. emdash_cli/commands/agent/handlers/rules.py +411 -0
  18. emdash_cli/commands/agent/handlers/sessions.py +168 -0
  19. emdash_cli/commands/agent/handlers/setup.py +715 -0
  20. emdash_cli/commands/agent/handlers/skills.py +478 -0
  21. emdash_cli/commands/agent/handlers/telegram.py +475 -0
  22. emdash_cli/commands/agent/handlers/todos.py +119 -0
  23. emdash_cli/commands/agent/handlers/verify.py +653 -0
  24. emdash_cli/commands/agent/help.py +236 -0
  25. emdash_cli/commands/agent/interactive.py +842 -0
  26. emdash_cli/commands/agent/menus.py +760 -0
  27. emdash_cli/commands/agent/onboarding.py +619 -0
  28. emdash_cli/commands/agent/session_restore.py +210 -0
  29. emdash_cli/commands/agent.py +7 -1321
  30. emdash_cli/commands/index.py +111 -13
  31. emdash_cli/commands/registry.py +635 -0
  32. emdash_cli/commands/server.py +99 -40
  33. emdash_cli/commands/skills.py +72 -6
  34. emdash_cli/design.py +328 -0
  35. emdash_cli/diff_renderer.py +438 -0
  36. emdash_cli/integrations/__init__.py +1 -0
  37. emdash_cli/integrations/telegram/__init__.py +15 -0
  38. emdash_cli/integrations/telegram/bot.py +402 -0
  39. emdash_cli/integrations/telegram/bridge.py +865 -0
  40. emdash_cli/integrations/telegram/config.py +155 -0
  41. emdash_cli/integrations/telegram/formatter.py +385 -0
  42. emdash_cli/main.py +52 -2
  43. emdash_cli/server_manager.py +70 -10
  44. emdash_cli/sse_renderer.py +659 -167
  45. {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.67.dist-info}/METADATA +2 -4
  46. emdash_cli-0.1.67.dist-info/RECORD +63 -0
  47. emdash_cli/commands/swarm.py +0 -86
  48. emdash_cli-0.1.35.dist-info/RECORD +0 -30
  49. {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.67.dist-info}/WHEEL +0 -0
  50. {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.67.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,319 @@
1
+ """Handler for /doctor command - environment diagnostics."""
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+
13
+ console = Console()
14
+
15
+ # Minimum required Python version
16
+ MIN_PYTHON_VERSION = (3, 10)
17
+
18
+ # Required packages
19
+ REQUIRED_PACKAGES = [
20
+ "emdash-cli",
21
+ "emdash-core",
22
+ "click",
23
+ "rich",
24
+ "httpx",
25
+ "prompt_toolkit",
26
+ ]
27
+
28
+ # Servers directory for per-repo servers
29
+ SERVERS_DIR = Path.home() / ".emdash" / "servers"
30
+
31
+
32
+ def check_python_version() -> tuple[bool, str, str]:
33
+ """Check Python version.
34
+
35
+ Returns:
36
+ Tuple of (passed, current_version, message)
37
+ """
38
+ current = sys.version_info[:2]
39
+ version_str = f"{current[0]}.{current[1]}"
40
+
41
+ if current >= MIN_PYTHON_VERSION:
42
+ return True, version_str, f"Python {version_str} (>= {MIN_PYTHON_VERSION[0]}.{MIN_PYTHON_VERSION[1]} required)"
43
+ else:
44
+ return False, version_str, f"Python {version_str} is below minimum {MIN_PYTHON_VERSION[0]}.{MIN_PYTHON_VERSION[1]}"
45
+
46
+
47
+ def check_package_installed(package: str) -> tuple[bool, str]:
48
+ """Check if a package is installed.
49
+
50
+ Returns:
51
+ Tuple of (installed, version_or_error)
52
+ """
53
+ try:
54
+ from importlib.metadata import version
55
+ ver = version(package)
56
+ return True, ver
57
+ except Exception:
58
+ return False, "not installed"
59
+
60
+
61
+ def check_git() -> tuple[bool, str]:
62
+ """Check if git is available.
63
+
64
+ Returns:
65
+ Tuple of (available, version_or_error)
66
+ """
67
+ try:
68
+ result = subprocess.run(
69
+ ["git", "--version"],
70
+ capture_output=True,
71
+ text=True,
72
+ check=True,
73
+ )
74
+ version = result.stdout.strip().replace("git version ", "")
75
+ return True, version
76
+ except Exception:
77
+ return False, "not found"
78
+
79
+
80
+ def check_git_repo() -> tuple[bool, str]:
81
+ """Check if current directory is a git repo.
82
+
83
+ Returns:
84
+ Tuple of (is_repo, message)
85
+ """
86
+ try:
87
+ result = subprocess.run(
88
+ ["git", "rev-parse", "--show-toplevel"],
89
+ capture_output=True,
90
+ text=True,
91
+ check=True,
92
+ )
93
+ repo_path = result.stdout.strip()
94
+ return True, repo_path
95
+ except Exception:
96
+ return False, "not a git repository"
97
+
98
+
99
+ def check_server_status() -> tuple[bool, str, list[dict]]:
100
+ """Check emdash server status.
101
+
102
+ Returns:
103
+ Tuple of (any_running, message, server_list)
104
+ """
105
+ import httpx
106
+
107
+ servers = []
108
+
109
+ # Check per-repo servers
110
+ if SERVERS_DIR.exists():
111
+ for port_file in SERVERS_DIR.glob("*.port"):
112
+ try:
113
+ port = int(port_file.read_text().strip())
114
+ hash_prefix = port_file.stem
115
+
116
+ # Get repo path
117
+ repo_file = SERVERS_DIR / f"{hash_prefix}.repo"
118
+ repo_path = repo_file.read_text().strip() if repo_file.exists() else "unknown"
119
+
120
+ # Check health
121
+ try:
122
+ response = httpx.get(f"http://localhost:{port}/api/health", timeout=2.0)
123
+ healthy = response.status_code == 200
124
+ except Exception:
125
+ healthy = False
126
+
127
+ servers.append({
128
+ "port": port,
129
+ "repo": repo_path,
130
+ "healthy": healthy,
131
+ })
132
+ except Exception:
133
+ pass
134
+
135
+ if servers:
136
+ healthy_count = sum(1 for s in servers if s["healthy"])
137
+ return True, f"{healthy_count}/{len(servers)} healthy", servers
138
+ else:
139
+ return False, "no servers running", []
140
+
141
+
142
+ def check_api_keys() -> list[tuple[str, bool, str]]:
143
+ """Check for common API keys.
144
+
145
+ Returns:
146
+ List of (key_name, is_set, hint)
147
+ """
148
+ keys = [
149
+ ("ANTHROPIC_API_KEY", "Required for Claude models"),
150
+ ("OPENAI_API_KEY", "Required for OpenAI models"),
151
+ ("FIREWORKS_API_KEY", "Required for Fireworks models"),
152
+ ("GITHUB_TOKEN", "Required for GitHub integration"),
153
+ ]
154
+
155
+ results = []
156
+ for key, hint in keys:
157
+ is_set = bool(os.environ.get(key))
158
+ results.append((key, is_set, hint))
159
+
160
+ return results
161
+
162
+
163
+ def check_disk_space() -> tuple[bool, str]:
164
+ """Check available disk space.
165
+
166
+ Returns:
167
+ Tuple of (sufficient, message)
168
+ """
169
+ try:
170
+ usage = shutil.disk_usage(Path.home())
171
+ free_gb = usage.free / (1024 ** 3)
172
+ if free_gb < 1:
173
+ return False, f"{free_gb:.1f} GB free (low!)"
174
+ else:
175
+ return True, f"{free_gb:.1f} GB free"
176
+ except Exception:
177
+ return True, "unknown"
178
+
179
+
180
+ def check_path_config() -> list[tuple[str, bool, str]]:
181
+ """Check if common bin directories are in PATH.
182
+
183
+ Returns:
184
+ List of (path, in_path, description)
185
+ """
186
+ path_env = os.environ.get("PATH", "")
187
+ paths_to_check = [
188
+ (Path.home() / ".local" / "bin", "pipx installs"),
189
+ (Path.home() / ".pyenv" / "shims", "pyenv"),
190
+ (Path("/opt/homebrew/bin"), "Homebrew (Apple Silicon)"),
191
+ (Path("/usr/local/bin"), "Homebrew (Intel) / system"),
192
+ ]
193
+
194
+ results = []
195
+ for path, desc in paths_to_check:
196
+ in_path = str(path) in path_env
197
+ results.append((str(path), in_path, desc))
198
+
199
+ return results
200
+
201
+
202
+ def handle_doctor(args: str) -> None:
203
+ """Handle /doctor command - run environment diagnostics."""
204
+ console.print()
205
+ console.print("[bold cyan]Emdash Doctor[/bold cyan] - Environment Diagnostics")
206
+ console.print()
207
+
208
+ issues = []
209
+
210
+ # Python Version
211
+ console.print("[bold]Python Environment[/bold]")
212
+ py_ok, py_ver, py_msg = check_python_version()
213
+ if py_ok:
214
+ console.print(f" [green]✓[/green] {py_msg}")
215
+ else:
216
+ console.print(f" [red]✗[/red] {py_msg}")
217
+ issues.append(("Python version", f"Upgrade to Python {MIN_PYTHON_VERSION[0]}.{MIN_PYTHON_VERSION[1]}+"))
218
+
219
+ console.print(f" [dim] Executable: {sys.executable}[/dim]")
220
+ console.print()
221
+
222
+ # Packages
223
+ console.print("[bold]Required Packages[/bold]")
224
+ for pkg in REQUIRED_PACKAGES:
225
+ installed, ver = check_package_installed(pkg)
226
+ if installed:
227
+ console.print(f" [green]✓[/green] {pkg} ({ver})")
228
+ else:
229
+ console.print(f" [red]✗[/red] {pkg} - {ver}")
230
+ issues.append((f"Package {pkg}", f"pip install {pkg}"))
231
+ console.print()
232
+
233
+ # Git
234
+ console.print("[bold]Git[/bold]")
235
+ git_ok, git_ver = check_git()
236
+ if git_ok:
237
+ console.print(f" [green]✓[/green] git ({git_ver})")
238
+ else:
239
+ console.print(f" [red]✗[/red] git not found")
240
+ issues.append(("Git", "Install git"))
241
+
242
+ repo_ok, repo_path = check_git_repo()
243
+ if repo_ok:
244
+ console.print(f" [green]✓[/green] In git repo: {repo_path}")
245
+ else:
246
+ console.print(f" [yellow]![/yellow] Not in a git repository")
247
+ console.print()
248
+
249
+ # Server Status
250
+ console.print("[bold]Emdash Server[/bold]")
251
+ srv_ok, srv_msg, servers = check_server_status()
252
+ if srv_ok:
253
+ console.print(f" [green]✓[/green] Servers: {srv_msg}")
254
+ for srv in servers:
255
+ status = "[green]healthy[/green]" if srv["healthy"] else "[red]unhealthy[/red]"
256
+ repo_short = srv["repo"].split("/")[-1] if "/" in srv["repo"] else srv["repo"]
257
+ console.print(f" Port {srv['port']}: {status} ({repo_short})")
258
+ else:
259
+ console.print(f" [dim]-[/dim] {srv_msg}")
260
+ console.print()
261
+
262
+ # API Keys
263
+ console.print("[bold]API Keys[/bold]")
264
+ api_keys = check_api_keys()
265
+ for key, is_set, hint in api_keys:
266
+ if is_set:
267
+ console.print(f" [green]✓[/green] {key} [dim]({hint})[/dim]")
268
+ else:
269
+ console.print(f" [dim]-[/dim] {key} not set [dim]({hint})[/dim]")
270
+ console.print()
271
+
272
+ # System
273
+ console.print("[bold]System[/bold]")
274
+ disk_ok, disk_msg = check_disk_space()
275
+ if disk_ok:
276
+ console.print(f" [green]✓[/green] Disk space: {disk_msg}")
277
+ else:
278
+ console.print(f" [yellow]![/yellow] Disk space: {disk_msg}")
279
+ issues.append(("Disk space", "Free up disk space"))
280
+
281
+ # PATH check
282
+ path_results = check_path_config()
283
+ local_bin = Path.home() / ".local" / "bin"
284
+ local_bin_in_path = str(local_bin) in os.environ.get("PATH", "")
285
+ if local_bin_in_path:
286
+ console.print(f" [green]✓[/green] ~/.local/bin in PATH (pipx)")
287
+ else:
288
+ console.print(f" [red]✗[/red] ~/.local/bin not in PATH")
289
+ issues.append(("PATH config", "Run: pipx ensurepath && source ~/.zshrc"))
290
+ console.print()
291
+
292
+ # Summary
293
+ if issues:
294
+ console.print("[bold red]Issues Found:[/bold red]")
295
+ console.print()
296
+ for issue, fix in issues:
297
+ console.print(f" [red]•[/red] {issue}")
298
+ console.print(f" [dim]Fix: {fix}[/dim]")
299
+ console.print()
300
+
301
+ # Python upgrade instructions if needed
302
+ if not py_ok:
303
+ console.print("[bold]To upgrade Python:[/bold]")
304
+ console.print(" [cyan]macOS:[/cyan] brew install python@3.12")
305
+ console.print(" [cyan]Ubuntu:[/cyan] sudo apt install python3.12")
306
+ console.print(" [cyan]Windows:[/cyan] winget install Python.Python.3.12")
307
+ console.print(" [cyan]pyenv:[/cyan] pyenv install 3.12 && pyenv global 3.12")
308
+ console.print()
309
+
310
+ # PATH fix instructions if needed
311
+ if not local_bin_in_path:
312
+ console.print("[bold]To fix PATH (for pipx/em command):[/bold]")
313
+ console.print(" [cyan]Option 1:[/cyan] pipx ensurepath")
314
+ console.print(" [cyan]Option 2:[/cyan] echo 'export PATH=\"$HOME/.local/bin:$PATH\"' >> ~/.zshrc")
315
+ console.print(" [dim]Then restart your terminal or run: source ~/.zshrc[/dim]")
316
+ console.print()
317
+ else:
318
+ console.print("[bold green]All checks passed![/bold green]")
319
+ console.print()
@@ -0,0 +1,121 @@
1
+ """Handler for /hooks command."""
2
+
3
+ import hashlib
4
+ from pathlib import Path
5
+
6
+ from rich.console import Console
7
+
8
+ console = Console()
9
+
10
+
11
+ def handle_hooks(args: str) -> None:
12
+ """Handle /hooks command.
13
+
14
+ Args:
15
+ args: Command arguments
16
+ """
17
+ from emdash_core.agent.hooks import HookManager, HookConfig, HookEventType
18
+
19
+ manager = HookManager(Path.cwd())
20
+
21
+ # Parse subcommand
22
+ subparts = args.split(maxsplit=1) if args else []
23
+ subcommand = subparts[0].lower() if subparts else "list"
24
+ subargs = subparts[1].strip() if len(subparts) > 1 else ""
25
+
26
+ if subcommand == "list" or subcommand == "":
27
+ # List all hooks
28
+ hooks = manager.get_hooks()
29
+ if hooks:
30
+ console.print("\n[bold cyan]Configured Hooks[/bold cyan]\n")
31
+ for hook in hooks:
32
+ status = "[green]enabled[/green]" if hook.enabled else "[dim]disabled[/dim]"
33
+ console.print(f" [cyan]{hook.id}[/cyan] ({status})")
34
+ console.print(f" Event: [yellow]{hook.event.value}[/yellow]")
35
+ console.print(f" Command: [dim]{hook.command}[/dim]")
36
+ console.print()
37
+ console.print(f"[dim]Config file: {manager.hooks_file_path}[/dim]\n")
38
+ else:
39
+ console.print("\n[dim]No hooks configured.[/dim]")
40
+ console.print("[dim]Add with: /hooks add <event> <command>[/dim]")
41
+ console.print(f"[dim]Events: {', '.join(e.value for e in HookEventType)}[/dim]\n")
42
+
43
+ elif subcommand == "add":
44
+ # Add a new hook: /hooks add <event> <command>
45
+ if not subargs:
46
+ console.print("[yellow]Usage: /hooks add <event> <command>[/yellow]")
47
+ console.print(f"[dim]Events: {', '.join(e.value for e in HookEventType)}[/dim]")
48
+ console.print("[dim]Example: /hooks add session_end notify-send 'Agent done'[/dim]")
49
+ else:
50
+ # Parse event and command
51
+ add_parts = subargs.split(maxsplit=1)
52
+ if len(add_parts) < 2:
53
+ console.print("[yellow]Usage: /hooks add <event> <command>[/yellow]")
54
+ else:
55
+ event_str = add_parts[0].lower()
56
+ hook_command = add_parts[1]
57
+
58
+ # Validate event type
59
+ try:
60
+ event_type = HookEventType(event_str)
61
+ except ValueError:
62
+ console.print(f"[red]Invalid event: {event_str}[/red]")
63
+ console.print(f"[dim]Valid events: {', '.join(e.value for e in HookEventType)}[/dim]")
64
+ return
65
+
66
+ # Generate a unique ID
67
+ hook_id = f"hook-{hashlib.md5(f'{event_str}{hook_command}'.encode()).hexdigest()[:8]}"
68
+
69
+ try:
70
+ hook = HookConfig(
71
+ id=hook_id,
72
+ event=event_type,
73
+ command=hook_command,
74
+ enabled=True,
75
+ )
76
+ manager.add_hook(hook)
77
+ console.print(f"[green]Added hook '{hook_id}'[/green]")
78
+ console.print(f"[dim]Event: {event_type.value}, Command: {hook_command}[/dim]")
79
+ except ValueError as e:
80
+ console.print(f"[red]Error: {e}[/red]")
81
+
82
+ elif subcommand == "remove":
83
+ if not subargs:
84
+ console.print("[yellow]Usage: /hooks remove <hook-id>[/yellow]")
85
+ else:
86
+ if manager.remove_hook(subargs):
87
+ console.print(f"[green]Removed hook '{subargs}'[/green]")
88
+ else:
89
+ console.print(f"[yellow]Hook '{subargs}' not found[/yellow]")
90
+
91
+ elif subcommand == "toggle":
92
+ if not subargs:
93
+ console.print("[yellow]Usage: /hooks toggle <hook-id>[/yellow]")
94
+ else:
95
+ new_state = manager.toggle_hook(subargs)
96
+ if new_state is not None:
97
+ state_str = "[green]enabled[/green]" if new_state else "[dim]disabled[/dim]"
98
+ console.print(f"Hook '{subargs}' is now {state_str}")
99
+ else:
100
+ console.print(f"[yellow]Hook '{subargs}' not found[/yellow]")
101
+
102
+ elif subcommand == "events":
103
+ # List available event types
104
+ console.print("\n[bold cyan]Available Hook Events[/bold cyan]\n")
105
+ event_descriptions = {
106
+ HookEventType.TOOL_START: "Triggered before a tool executes",
107
+ HookEventType.TOOL_RESULT: "Triggered after a tool completes",
108
+ HookEventType.SESSION_START: "Triggered when agent session starts",
109
+ HookEventType.SESSION_END: "Triggered when agent session ends",
110
+ HookEventType.RESPONSE: "Triggered when agent produces a response",
111
+ HookEventType.ERROR: "Triggered when an error occurs",
112
+ }
113
+ for event_type in HookEventType:
114
+ desc = event_descriptions.get(event_type, "")
115
+ console.print(f" [cyan]{event_type.value}[/cyan]")
116
+ console.print(f" [dim]{desc}[/dim]")
117
+ console.print()
118
+
119
+ else:
120
+ console.print(f"[yellow]Unknown subcommand: {subcommand}[/yellow]")
121
+ console.print("[dim]Usage: /hooks [list|add|remove|toggle|events][/dim]")
@@ -0,0 +1,183 @@
1
+ """Handler for /index command."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from rich.console import Console
7
+
8
+ console = Console()
9
+
10
+
11
+ def handle_index(args: str, client) -> None:
12
+ """Handle /index command.
13
+
14
+ Args:
15
+ args: Command arguments (status, start, hook install/uninstall)
16
+ client: EmdashClient instance
17
+ """
18
+ # Parse subcommand
19
+ subparts = args.split(maxsplit=1) if args else []
20
+ subcommand = subparts[0].lower() if subparts else "status"
21
+ subargs = subparts[1].strip() if len(subparts) > 1 else ""
22
+
23
+ repo_path = os.getcwd()
24
+
25
+ if subcommand == "status":
26
+ _show_status(client, repo_path)
27
+
28
+ elif subcommand == "start":
29
+ _start_index(client, repo_path, subargs)
30
+
31
+ elif subcommand == "hook":
32
+ _handle_hook(repo_path, subargs)
33
+
34
+ else:
35
+ console.print(f"[yellow]Unknown subcommand: {subcommand}[/yellow]")
36
+ console.print("[dim]Usage: /index [status|start|hook][/dim]")
37
+ console.print("[dim] /index - Show index status[/dim]")
38
+ console.print("[dim] /index start - Start incremental indexing[/dim]")
39
+ console.print("[dim] /index start --full - Force full reindex[/dim]")
40
+ console.print("[dim] /index hook install - Install post-commit hook[/dim]")
41
+ console.print("[dim] /index hook uninstall - Remove post-commit hook[/dim]")
42
+
43
+
44
+ def _show_status(client, repo_path: str) -> None:
45
+ """Show index status."""
46
+ try:
47
+ status = client.index_status(repo_path)
48
+
49
+ console.print("\n[bold cyan]Index Status[/bold cyan]\n")
50
+ is_indexed = status.get("is_indexed", False)
51
+ console.print(f" Indexed: {'[green]Yes[/green]' if is_indexed else '[yellow]No[/yellow]'}")
52
+
53
+ if is_indexed:
54
+ console.print(f" Files: {status.get('file_count', 0)}")
55
+ console.print(f" Functions: {status.get('function_count', 0)}")
56
+ console.print(f" Classes: {status.get('class_count', 0)}")
57
+ console.print(f" Communities: {status.get('community_count', 0)}")
58
+
59
+ if status.get("last_indexed"):
60
+ console.print(f" Last indexed: {status.get('last_indexed')}")
61
+ if status.get("last_commit"):
62
+ console.print(f" Last commit: {status.get('last_commit')[:8]}")
63
+
64
+ # Check hook status
65
+ hooks_dir = Path(repo_path) / ".git" / "hooks"
66
+ hook_path = hooks_dir / "post-commit"
67
+ if hook_path.exists() and "emdash" in hook_path.read_text():
68
+ console.print(f" Auto-index: [green]Enabled[/green] (post-commit hook)")
69
+ else:
70
+ console.print(f" Auto-index: [dim]Disabled[/dim] (run /index hook install)")
71
+
72
+ console.print()
73
+
74
+ except Exception as e:
75
+ console.print(f"[red]Error getting status: {e}[/red]")
76
+
77
+
78
+ def _start_index(client, repo_path: str, args: str) -> None:
79
+ """Start indexing."""
80
+ import json
81
+ from rich.progress import Progress, BarColumn, TaskProgressColumn, TextColumn
82
+
83
+ # Parse options
84
+ full = "--full" in args
85
+
86
+ console.print(f"\n[bold cyan]Indexing[/bold cyan] {repo_path}\n")
87
+
88
+ try:
89
+ with Progress(
90
+ TextColumn("[bold cyan]{task.description}[/bold cyan]"),
91
+ BarColumn(bar_width=40, complete_style="cyan", finished_style="green"),
92
+ TaskProgressColumn(),
93
+ console=console,
94
+ transient=True,
95
+ ) as progress:
96
+ task = progress.add_task("Starting...", total=100)
97
+
98
+ for line in client.index_start_stream(repo_path, not full):
99
+ line = line.strip()
100
+ if line.startswith("event: "):
101
+ continue
102
+ if line.startswith("data: "):
103
+ try:
104
+ data = json.loads(line[6:])
105
+ step = data.get("step") or data.get("message", "")
106
+ percent = data.get("percent")
107
+
108
+ if step:
109
+ progress.update(task, description=step)
110
+ if percent is not None:
111
+ progress.update(task, completed=percent)
112
+ except json.JSONDecodeError:
113
+ pass
114
+
115
+ progress.update(task, completed=100, description="Complete")
116
+
117
+ console.print("[bold green]Indexing complete![/bold green]\n")
118
+
119
+ except Exception as e:
120
+ console.print(f"[red]Error: {e}[/red]")
121
+
122
+
123
+ def _handle_hook(repo_path: str, args: str) -> None:
124
+ """Handle hook install/uninstall."""
125
+ action = args.lower() if args else ""
126
+
127
+ if action not in ("install", "uninstall"):
128
+ console.print("[yellow]Usage: /index hook [install|uninstall][/yellow]")
129
+ return
130
+
131
+ hooks_dir = Path(repo_path) / ".git" / "hooks"
132
+ hook_path = hooks_dir / "post-commit"
133
+
134
+ if not hooks_dir.exists():
135
+ console.print(f"[red]Error:[/red] Not a git repository: {repo_path}")
136
+ return
137
+
138
+ hook_content = """#!/bin/sh
139
+ # emdash post-commit hook - auto-reindex on commit
140
+ # Installed by: emdash index hook install
141
+
142
+ # Run indexing in background to not block the commit
143
+ emdash index start > /dev/null 2>&1 &
144
+ """
145
+
146
+ if action == "install":
147
+ if hook_path.exists():
148
+ existing = hook_path.read_text()
149
+ if "emdash" in existing:
150
+ console.print("[yellow]Hook already installed[/yellow]")
151
+ return
152
+ else:
153
+ console.print("[yellow]Appending to existing post-commit hook[/yellow]")
154
+ with open(hook_path, "a") as f:
155
+ f.write("\n# emdash auto-index\nemdash index start > /dev/null 2>&1 &\n")
156
+ else:
157
+ hook_path.write_text(hook_content)
158
+
159
+ hook_path.chmod(0o755)
160
+ console.print(f"[green]Post-commit hook installed[/green]")
161
+ console.print("[dim]Index will update automatically after each commit[/dim]")
162
+
163
+ elif action == "uninstall":
164
+ if not hook_path.exists():
165
+ console.print("[yellow]No post-commit hook found[/yellow]")
166
+ return
167
+
168
+ existing = hook_path.read_text()
169
+ if "emdash" not in existing:
170
+ console.print("[yellow]No emdash hook found in post-commit[/yellow]")
171
+ return
172
+
173
+ if existing.strip() == hook_content.strip():
174
+ hook_path.unlink()
175
+ console.print("[green]Post-commit hook removed[/green]")
176
+ else:
177
+ lines = existing.split("\n")
178
+ new_lines = [
179
+ line for line in lines
180
+ if "emdash" not in line and "auto-reindex" not in line
181
+ ]
182
+ hook_path.write_text("\n".join(new_lines))
183
+ console.print("[green]Emdash hook lines removed from post-commit[/green]")