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.
- emdash_cli/__init__.py +15 -0
- emdash_cli/client.py +156 -0
- emdash_cli/clipboard.py +30 -61
- emdash_cli/commands/agent/__init__.py +14 -0
- emdash_cli/commands/agent/cli.py +100 -0
- emdash_cli/commands/agent/constants.py +53 -0
- emdash_cli/commands/agent/file_utils.py +178 -0
- emdash_cli/commands/agent/handlers/__init__.py +41 -0
- emdash_cli/commands/agent/handlers/agents.py +421 -0
- emdash_cli/commands/agent/handlers/auth.py +69 -0
- emdash_cli/commands/agent/handlers/doctor.py +319 -0
- emdash_cli/commands/agent/handlers/hooks.py +121 -0
- emdash_cli/commands/agent/handlers/mcp.py +183 -0
- emdash_cli/commands/agent/handlers/misc.py +200 -0
- emdash_cli/commands/agent/handlers/rules.py +394 -0
- emdash_cli/commands/agent/handlers/sessions.py +168 -0
- emdash_cli/commands/agent/handlers/setup.py +582 -0
- emdash_cli/commands/agent/handlers/skills.py +440 -0
- emdash_cli/commands/agent/handlers/todos.py +98 -0
- emdash_cli/commands/agent/handlers/verify.py +648 -0
- emdash_cli/commands/agent/interactive.py +657 -0
- emdash_cli/commands/agent/menus.py +728 -0
- emdash_cli/commands/agent.py +7 -856
- emdash_cli/commands/server.py +99 -40
- emdash_cli/server_manager.py +70 -10
- emdash_cli/session_store.py +321 -0
- emdash_cli/sse_renderer.py +256 -110
- {emdash_cli-0.1.30.dist-info → emdash_cli-0.1.46.dist-info}/METADATA +2 -4
- emdash_cli-0.1.46.dist-info/RECORD +49 -0
- emdash_cli-0.1.30.dist-info/RECORD +0 -29
- {emdash_cli-0.1.30.dist-info → emdash_cli-0.1.46.dist-info}/WHEEL +0 -0
- {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]")
|