emdash-cli 0.1.30__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 (32) hide show
  1. emdash_cli/__init__.py +15 -0
  2. emdash_cli/client.py +156 -0
  3. emdash_cli/clipboard.py +30 -61
  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 +53 -0
  7. emdash_cli/commands/agent/file_utils.py +178 -0
  8. emdash_cli/commands/agent/handlers/__init__.py +41 -0
  9. emdash_cli/commands/agent/handlers/agents.py +421 -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/mcp.py +183 -0
  14. emdash_cli/commands/agent/handlers/misc.py +200 -0
  15. emdash_cli/commands/agent/handlers/rules.py +394 -0
  16. emdash_cli/commands/agent/handlers/sessions.py +168 -0
  17. emdash_cli/commands/agent/handlers/setup.py +582 -0
  18. emdash_cli/commands/agent/handlers/skills.py +440 -0
  19. emdash_cli/commands/agent/handlers/todos.py +98 -0
  20. emdash_cli/commands/agent/handlers/verify.py +648 -0
  21. emdash_cli/commands/agent/interactive.py +657 -0
  22. emdash_cli/commands/agent/menus.py +728 -0
  23. emdash_cli/commands/agent.py +7 -856
  24. emdash_cli/commands/server.py +99 -40
  25. emdash_cli/server_manager.py +70 -10
  26. emdash_cli/session_store.py +321 -0
  27. emdash_cli/sse_renderer.py +256 -110
  28. {emdash_cli-0.1.30.dist-info → emdash_cli-0.1.46.dist-info}/METADATA +2 -4
  29. emdash_cli-0.1.46.dist-info/RECORD +49 -0
  30. emdash_cli-0.1.30.dist-info/RECORD +0 -29
  31. {emdash_cli-0.1.30.dist-info → emdash_cli-0.1.46.dist-info}/WHEEL +0 -0
  32. {emdash_cli-0.1.30.dist-info → emdash_cli-0.1.46.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,69 @@
1
+ """Handler for /auth command."""
2
+
3
+ from rich.console import Console
4
+
5
+ console = Console()
6
+
7
+
8
+ def handle_auth(args: str) -> None:
9
+ """Handle /auth command.
10
+
11
+ Args:
12
+ args: Command arguments
13
+ """
14
+ from emdash_core.auth.github import GitHubAuth, get_auth_status
15
+
16
+ # Parse subcommand
17
+ subparts = args.split(maxsplit=1) if args else []
18
+ subcommand = subparts[0].lower() if subparts else "status"
19
+
20
+ if subcommand == "status" or subcommand == "":
21
+ # Show auth status
22
+ status = get_auth_status()
23
+ console.print()
24
+ console.print("[bold cyan]GitHub Authentication[/bold cyan]\n")
25
+
26
+ if status["authenticated"]:
27
+ console.print(f" Status: [green]Authenticated[/green]")
28
+ console.print(f" Source: {status['source']}")
29
+ if status["username"]:
30
+ console.print(f" Username: @{status['username']}")
31
+ if status["scopes"]:
32
+ console.print(f" Scopes: {', '.join(status['scopes'])}")
33
+ else:
34
+ console.print(f" Status: [yellow]Not authenticated[/yellow]")
35
+ console.print("\n[dim]Run /auth login to authenticate with GitHub[/dim]")
36
+
37
+ console.print()
38
+
39
+ elif subcommand == "login":
40
+ # Start GitHub OAuth device flow
41
+ console.print()
42
+ console.print("[bold cyan]GitHub Login[/bold cyan]")
43
+ console.print("[dim]Starting device authorization flow...[/dim]\n")
44
+
45
+ auth = GitHubAuth()
46
+ try:
47
+ config = auth.login(open_browser=True)
48
+ if config:
49
+ console.print()
50
+ console.print("[green]Authentication successful![/green]")
51
+ console.print("[dim]MCP servers can now use ${GITHUB_TOKEN}[/dim]")
52
+ else:
53
+ console.print("[red]Authentication failed or was cancelled.[/red]")
54
+ except Exception as e:
55
+ console.print(f"[red]Login failed: {e}[/red]")
56
+
57
+ console.print()
58
+
59
+ elif subcommand == "logout":
60
+ # Remove stored authentication
61
+ auth = GitHubAuth()
62
+ if auth.logout():
63
+ console.print("[green]Logged out successfully[/green]")
64
+ else:
65
+ console.print("[dim]No stored authentication to remove[/dim]")
66
+
67
+ else:
68
+ console.print(f"[yellow]Unknown subcommand: {subcommand}[/yellow]")
69
+ console.print("[dim]Usage: /auth [status|login|logout][/dim]")
@@ -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 /mcp command."""
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from rich.console import Console
9
+
10
+ console = Console()
11
+
12
+
13
+ def handle_mcp(args: str) -> None:
14
+ """Handle /mcp command.
15
+
16
+ Args:
17
+ args: Command arguments
18
+ """
19
+ from emdash_core.agent.mcp.manager import get_mcp_manager
20
+ from emdash_core.agent.mcp.config import get_default_mcp_config_path
21
+
22
+ manager = get_mcp_manager(config_path=get_default_mcp_config_path(Path.cwd()))
23
+
24
+ # Parse subcommand
25
+ subparts = args.split(maxsplit=1) if args else []
26
+ subcommand = subparts[0].lower() if subparts else ""
27
+
28
+ def _show_mcp_interactive():
29
+ """Show interactive MCP server list with toggle."""
30
+ from prompt_toolkit import Application
31
+ from prompt_toolkit.key_binding import KeyBindings
32
+ from prompt_toolkit.layout import Layout, Window, FormattedTextControl
33
+ from prompt_toolkit.styles import Style
34
+
35
+ servers = manager.list_servers()
36
+ if not servers:
37
+ console.print("\n[dim]No global MCP servers configured.[/dim]")
38
+ console.print(f"[dim]Edit {manager.config_path} to add servers[/dim]\n")
39
+ return None
40
+
41
+ selected_index = [0]
42
+ server_names = [s["name"] for s in servers]
43
+
44
+ kb = KeyBindings()
45
+
46
+ @kb.add("up")
47
+ @kb.add("k")
48
+ def move_up(event):
49
+ selected_index[0] = (selected_index[0] - 1) % len(servers)
50
+
51
+ @kb.add("down")
52
+ @kb.add("j")
53
+ def move_down(event):
54
+ selected_index[0] = (selected_index[0] + 1) % len(servers)
55
+
56
+ @kb.add("enter")
57
+ @kb.add("space")
58
+ def toggle_server(event):
59
+ # Toggle the selected server's enabled status
60
+ server_name = server_names[selected_index[0]]
61
+ config_path = manager.config_path
62
+
63
+ # Read current config
64
+ if config_path.exists():
65
+ with open(config_path) as f:
66
+ config = json.load(f)
67
+ else:
68
+ config = {"mcpServers": {}}
69
+
70
+ # Toggle enabled status
71
+ if server_name in config.get("mcpServers", {}):
72
+ current = config["mcpServers"][server_name].get("enabled", True)
73
+ config["mcpServers"][server_name]["enabled"] = not current
74
+
75
+ # Save config
76
+ with open(config_path, "w") as f:
77
+ json.dump(config, f, indent=2)
78
+
79
+ # Reload manager
80
+ manager.reload_config()
81
+
82
+ # Update local servers list
83
+ servers[:] = manager.list_servers()
84
+
85
+ @kb.add("e")
86
+ def edit_config(event):
87
+ event.app.exit(result="edit")
88
+
89
+ @kb.add("q")
90
+ @kb.add("escape")
91
+ def quit_menu(event):
92
+ event.app.exit()
93
+
94
+ def get_formatted_content():
95
+ lines = []
96
+ lines.append(("class:title", "Global MCP Servers\n"))
97
+ lines.append(("class:dim", f"Config: {manager.config_path}\n\n"))
98
+
99
+ for i, server in enumerate(servers):
100
+ if server["enabled"]:
101
+ if server["running"]:
102
+ status = "running"
103
+ status_style = "class:running"
104
+ else:
105
+ status = "enabled"
106
+ status_style = "class:enabled"
107
+ else:
108
+ status = "disabled"
109
+ status_style = "class:disabled"
110
+
111
+ if i == selected_index[0]:
112
+ lines.append(("class:selected", f" > {server['name']}"))
113
+ else:
114
+ lines.append(("class:normal", f" {server['name']}"))
115
+
116
+ lines.append((status_style, f" ({status})\n"))
117
+
118
+ lines.append(("class:hint", "\n↑/↓ navigate • Enter toggle • e edit • q quit"))
119
+ return lines
120
+
121
+ style = Style.from_dict({
122
+ "title": "#00cc66 bold",
123
+ "dim": "#888888",
124
+ "selected": "#00cc66 bold",
125
+ "normal": "#cccccc",
126
+ "running": "#00cc66",
127
+ "enabled": "#cccc00",
128
+ "disabled": "#888888",
129
+ "hint": "#888888 italic",
130
+ })
131
+
132
+ layout = Layout(Window(FormattedTextControl(get_formatted_content)))
133
+ app = Application(layout=layout, key_bindings=kb, style=style, full_screen=False)
134
+
135
+ result = app.run()
136
+ if result == "edit":
137
+ return "edit"
138
+ return None
139
+
140
+ if subcommand == "" or subcommand == "list":
141
+ # Show interactive menu (default)
142
+ result = _show_mcp_interactive()
143
+ if result == "edit":
144
+ subcommand = "edit"
145
+ else:
146
+ # Don't continue to edit
147
+ subcommand = "done"
148
+
149
+ if subcommand == "edit":
150
+ # Open MCP config in editor
151
+ config_path = manager.config_path
152
+
153
+ # Create default config if it doesn't exist
154
+ if not config_path.exists():
155
+ config_path.parent.mkdir(parents=True, exist_ok=True)
156
+ config_path.write_text('{\n "mcpServers": {}\n}\n')
157
+ console.print(f"[dim]Created {config_path}[/dim]")
158
+
159
+ editor = os.environ.get("EDITOR", "")
160
+ if not editor:
161
+ for ed in ["code", "vim", "nano", "vi"]:
162
+ try:
163
+ subprocess.run(["which", ed], capture_output=True, check=True)
164
+ editor = ed
165
+ break
166
+ except (subprocess.CalledProcessError, FileNotFoundError):
167
+ continue
168
+
169
+ if editor:
170
+ console.print(f"[dim]Opening {config_path} in {editor}...[/dim]")
171
+ try:
172
+ subprocess.run([editor, str(config_path)])
173
+ manager.reload_config()
174
+ console.print("[dim]Config reloaded[/dim]")
175
+ except Exception as e:
176
+ console.print(f"[red]Failed to open editor: {e}[/red]")
177
+ else:
178
+ console.print(f"[yellow]No editor found. Edit manually:[/yellow]")
179
+ console.print(f" {config_path}")
180
+
181
+ elif subcommand != "done":
182
+ console.print(f"[yellow]Unknown subcommand: {subcommand}[/yellow]")
183
+ console.print("[dim]Usage: /mcp [list|edit][/dim]")