aleph-rlm 0.6.0__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.
aleph/cli.py ADDED
@@ -0,0 +1,1044 @@
1
+ """CLI installer for Aleph MCP server.
2
+
3
+ Provides easy installation of Aleph into various MCP clients:
4
+ - Claude Desktop (macOS/Windows)
5
+ - Cursor (global/project)
6
+ - Windsurf
7
+ - Claude Code
8
+ - VSCode
9
+ - Codex CLI
10
+ - Gemini CLI
11
+
12
+ Usage:
13
+ aleph-rlm install # Interactive mode, detects all clients
14
+ aleph-rlm install claude-desktop
15
+ aleph-rlm install cursor
16
+ aleph-rlm install windsurf
17
+ aleph-rlm install claude-code
18
+ aleph-rlm install codex
19
+ aleph-rlm install gemini
20
+ aleph-rlm install --all # Configure all detected clients
21
+ aleph-rlm uninstall <client>
22
+ aleph-rlm doctor # Verify installation
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import platform
30
+ import re
31
+ import shutil
32
+ import subprocess
33
+ import sys
34
+ from dataclasses import dataclass
35
+ from datetime import datetime
36
+ from pathlib import Path
37
+ from typing import Callable
38
+
39
+ __all__ = ["main"]
40
+
41
+ # Try to import rich for colored output, fall back to plain text
42
+ try:
43
+ from rich.console import Console
44
+ from rich.panel import Panel
45
+ from rich.table import Table
46
+ from rich import print as rprint
47
+ RICH_AVAILABLE = True
48
+ console = Console()
49
+ except ImportError:
50
+ RICH_AVAILABLE = False
51
+ console = None
52
+
53
+
54
+ # =============================================================================
55
+ # Output helpers (with/without rich)
56
+ # =============================================================================
57
+
58
+ def print_success(msg: str) -> None:
59
+ """Print success message in green."""
60
+ if RICH_AVAILABLE:
61
+ console.print(f"[green]{msg}[/green]")
62
+ else:
63
+ print(f"SUCCESS: {msg}")
64
+
65
+
66
+ def print_error(msg: str) -> None:
67
+ """Print error message in red."""
68
+ if RICH_AVAILABLE:
69
+ console.print(f"[red]{msg}[/red]")
70
+ else:
71
+ print(f"ERROR: {msg}", file=sys.stderr)
72
+
73
+
74
+ def print_warning(msg: str) -> None:
75
+ """Print warning message in yellow."""
76
+ if RICH_AVAILABLE:
77
+ console.print(f"[yellow]{msg}[/yellow]")
78
+ else:
79
+ print(f"WARNING: {msg}")
80
+
81
+
82
+ def print_info(msg: str) -> None:
83
+ """Print info message in blue."""
84
+ if RICH_AVAILABLE:
85
+ console.print(f"[blue]{msg}[/blue]")
86
+ else:
87
+ print(msg)
88
+
89
+
90
+ def print_header(title: str) -> None:
91
+ """Print a header/title."""
92
+ if RICH_AVAILABLE:
93
+ console.print(Panel(title, style="bold cyan"))
94
+ else:
95
+ print(f"\n{'=' * 50}")
96
+ print(f" {title}")
97
+ print(f"{'=' * 50}\n")
98
+
99
+
100
+ def print_table(title: str, rows: list[tuple[str, str, str]]) -> None:
101
+ """Print a table with Client, Status, Path columns."""
102
+ if RICH_AVAILABLE:
103
+ table = Table(title=title)
104
+ table.add_column("Client", style="cyan")
105
+ table.add_column("Status", style="green")
106
+ table.add_column("Path")
107
+ for row in rows:
108
+ table.add_row(*row)
109
+ console.print(table)
110
+ else:
111
+ print(f"\n{title}")
112
+ print("-" * 70)
113
+ print(f"{'Client':<20} {'Status':<15} {'Path'}")
114
+ print("-" * 70)
115
+ for client, status, path in rows:
116
+ print(f"{client:<20} {status:<15} {path}")
117
+ print()
118
+
119
+
120
+ # =============================================================================
121
+ # Client configuration
122
+ # =============================================================================
123
+
124
+ @dataclass
125
+ class ClientConfig:
126
+ """Configuration for an MCP client."""
127
+ name: str
128
+ display_name: str
129
+ config_path: Callable[[], Path | None]
130
+ is_cli: bool = False # True for Claude Code which uses CLI commands
131
+ restart_instruction: str = ""
132
+ config_format: str = "json"
133
+
134
+ def get_path(self) -> Path | None:
135
+ """Get the config path, returns None if not applicable."""
136
+ return self.config_path()
137
+
138
+
139
+ def _get_claude_desktop_path() -> Path | None:
140
+ """Get Claude Desktop config path based on platform."""
141
+ system = platform.system()
142
+ if system == "Darwin": # macOS
143
+ return Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
144
+ elif system == "Windows":
145
+ appdata = os.environ.get("APPDATA")
146
+ if appdata:
147
+ return Path(appdata) / "Claude" / "claude_desktop_config.json"
148
+ elif system == "Linux":
149
+ # XDG-compliant path
150
+ config_home = os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")
151
+ return Path(config_home) / "Claude" / "claude_desktop_config.json"
152
+ return None
153
+
154
+
155
+ def _get_cursor_global_path() -> Path | None:
156
+ """Get Cursor global config path."""
157
+ return Path.home() / ".cursor" / "mcp.json"
158
+
159
+
160
+ def _get_cursor_project_path() -> Path | None:
161
+ """Get Cursor project-level config path (current directory)."""
162
+ return Path.cwd() / ".cursor" / "mcp.json"
163
+
164
+
165
+ def _get_windsurf_path() -> Path | None:
166
+ """Get Windsurf config path."""
167
+ return Path.home() / ".codeium" / "windsurf" / "mcp_config.json"
168
+
169
+
170
+ def _get_vscode_path() -> Path | None:
171
+ """Get VSCode project-level config path."""
172
+ return Path.cwd() / ".vscode" / "mcp.json"
173
+
174
+
175
+ def _get_claude_code_path() -> Path | None:
176
+ """Claude Code uses CLI, not a config file."""
177
+ return None
178
+
179
+
180
+ def _get_codex_path() -> Path | None:
181
+ """Get Codex CLI config path."""
182
+ return Path.home() / ".codex" / "config.toml"
183
+
184
+ def _get_gemini_path() -> Path | None:
185
+ """Get Gemini CLI config path."""
186
+ return Path.home() / ".gemini" / "mcp.json"
187
+
188
+
189
+ # Define all supported clients
190
+ CLIENTS: dict[str, ClientConfig] = {
191
+ "claude-desktop": ClientConfig(
192
+ name="claude-desktop",
193
+ display_name="Claude Desktop",
194
+ config_path=_get_claude_desktop_path,
195
+ restart_instruction="Restart Claude Desktop to load Aleph",
196
+ ),
197
+ "cursor": ClientConfig(
198
+ name="cursor",
199
+ display_name="Cursor (Global)",
200
+ config_path=_get_cursor_global_path,
201
+ restart_instruction="Restart Cursor to load Aleph",
202
+ ),
203
+ "cursor-project": ClientConfig(
204
+ name="cursor-project",
205
+ display_name="Cursor (Project)",
206
+ config_path=_get_cursor_project_path,
207
+ restart_instruction="Restart Cursor to load Aleph",
208
+ ),
209
+ "windsurf": ClientConfig(
210
+ name="windsurf",
211
+ display_name="Windsurf",
212
+ config_path=_get_windsurf_path,
213
+ restart_instruction="Restart Windsurf to load Aleph",
214
+ ),
215
+ "vscode": ClientConfig(
216
+ name="vscode",
217
+ display_name="VSCode (Project)",
218
+ config_path=_get_vscode_path,
219
+ restart_instruction="Restart VSCode to load Aleph",
220
+ ),
221
+ "claude-code": ClientConfig(
222
+ name="claude-code",
223
+ display_name="Claude Code",
224
+ config_path=_get_claude_code_path,
225
+ is_cli=True,
226
+ restart_instruction="Run 'claude' to use Aleph",
227
+ ),
228
+ "codex": ClientConfig(
229
+ name="codex",
230
+ display_name="Codex CLI",
231
+ config_path=_get_codex_path,
232
+ restart_instruction="Restart Codex CLI to load Aleph",
233
+ config_format="toml",
234
+ ),
235
+ "gemini": ClientConfig(
236
+ name="gemini",
237
+ display_name="Gemini CLI",
238
+ config_path=_get_gemini_path,
239
+ restart_instruction="Restart Gemini CLI to load Aleph",
240
+ ),
241
+ }
242
+
243
+ # The JSON configuration to inject
244
+ ALEPH_MCP_CONFIG = {
245
+ "command": "aleph",
246
+ "args": ["--enable-actions", "--workspace-mode", "any", "--tool-docs", "concise"],
247
+ }
248
+
249
+
250
+ # =============================================================================
251
+ # Detection and installation logic
252
+ # =============================================================================
253
+
254
+ def _find_claude_cli() -> str | None:
255
+ """Find the Claude Code CLI executable.
256
+
257
+ On Windows with NPM installation, the executable may be claude.cmd or claude.ps1.
258
+ Returns the executable name if found, None otherwise.
259
+ """
260
+ # Try standard 'claude' first (works on macOS/Linux and some Windows setups)
261
+ if shutil.which("claude"):
262
+ return "claude"
263
+
264
+ # On Windows, NPM creates .cmd and .ps1 wrapper scripts
265
+ if platform.system() == "Windows":
266
+ for ext in (".cmd", ".ps1", ".exe"):
267
+ exe_name = f"claude{ext}"
268
+ if shutil.which(exe_name):
269
+ return exe_name
270
+
271
+ # Also check common npm global bin locations on Windows
272
+ npm_paths = []
273
+ appdata = os.environ.get("APPDATA")
274
+ if appdata:
275
+ npm_paths.append(Path(appdata) / "npm" / "claude.cmd")
276
+ npm_paths.append(Path(appdata) / "npm" / "claude.ps1")
277
+
278
+ localappdata = os.environ.get("LOCALAPPDATA")
279
+ if localappdata:
280
+ npm_paths.append(Path(localappdata) / "npm" / "claude.cmd")
281
+ npm_paths.append(Path(localappdata) / "npm" / "claude.ps1")
282
+
283
+ for npm_path in npm_paths:
284
+ if npm_path.exists():
285
+ return str(npm_path)
286
+
287
+ return None
288
+
289
+
290
+ def is_client_installed(client: ClientConfig) -> bool:
291
+ """Check if a client appears to be installed."""
292
+ if client.is_cli:
293
+ # Check if claude CLI is available
294
+ return _find_claude_cli() is not None
295
+
296
+ path = client.get_path()
297
+ if path is None:
298
+ return False
299
+
300
+ # Check if the config directory exists (client is likely installed)
301
+ # For Claude Desktop, check the parent directory
302
+ if client.name == "claude-desktop":
303
+ return path.parent.exists()
304
+
305
+ # For editors, we check if the global config dir exists
306
+ if client.name == "cursor":
307
+ return path.parent.exists()
308
+
309
+ if client.name == "windsurf":
310
+ return path.parent.exists()
311
+
312
+ if client.name == "codex":
313
+ return path.parent.exists()
314
+
315
+ if client.name == "gemini":
316
+ return path.parent.exists()
317
+
318
+ # For project-level configs, always return True (user may want to create)
319
+ return True
320
+
321
+
322
+ def is_aleph_configured(client: ClientConfig) -> bool:
323
+ """Check if Aleph is already configured in a client."""
324
+ if client.is_cli:
325
+ # Check claude mcp list
326
+ claude_exe = _find_claude_cli()
327
+ if not claude_exe:
328
+ return False
329
+ try:
330
+ result = subprocess.run(
331
+ [claude_exe, "mcp", "list"],
332
+ capture_output=True,
333
+ text=True,
334
+ timeout=10,
335
+ shell=claude_exe.endswith((".cmd", ".ps1")), # Use shell for Windows scripts
336
+ )
337
+ return "aleph" in result.stdout.lower()
338
+ except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
339
+ return False
340
+
341
+ if client.config_format == "toml":
342
+ return is_aleph_configured_toml(client)
343
+
344
+ path = client.get_path()
345
+ if path is None or not path.exists():
346
+ return False
347
+
348
+ try:
349
+ with open(path, "r", encoding="utf-8") as f:
350
+ config = json.load(f)
351
+ return "aleph" in config.get("mcpServers", {})
352
+ except (json.JSONDecodeError, OSError):
353
+ return False
354
+
355
+
356
+ def backup_config(path: Path) -> Path | None:
357
+ """Create a backup of the config file."""
358
+ if not path.exists():
359
+ return None
360
+
361
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
362
+ backup_path = path.with_suffix(f".backup_{timestamp}.json")
363
+
364
+ try:
365
+ shutil.copy2(path, backup_path)
366
+ return backup_path
367
+ except OSError as e:
368
+ print_warning(f"Could not create backup: {e}")
369
+ return None
370
+
371
+
372
+ def backup_config_toml(path: Path) -> Path | None:
373
+ """Create a backup of a TOML config file."""
374
+ if not path.exists():
375
+ return None
376
+
377
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
378
+ backup_path = path.with_suffix(f"{path.suffix}.backup_{timestamp}")
379
+
380
+ try:
381
+ shutil.copy2(path, backup_path)
382
+ return backup_path
383
+ except OSError as e:
384
+ print_warning(f"Could not create backup: {e}")
385
+ return None
386
+
387
+
388
+ def validate_json(path: Path) -> bool:
389
+ """Validate that a JSON file is well-formed."""
390
+ try:
391
+ with open(path, "r", encoding="utf-8") as f:
392
+ json.load(f)
393
+ return True
394
+ except (json.JSONDecodeError, OSError):
395
+ return False
396
+
397
+
398
+ def validate_toml(path: Path) -> bool:
399
+ """Validate that a TOML file is well-formed when tomllib/tomli is available."""
400
+ try:
401
+ import tomllib # type: ignore[attr-defined]
402
+ except ImportError:
403
+ try:
404
+ import tomli as tomllib # type: ignore[no-redef]
405
+ except ImportError:
406
+ return True
407
+ try:
408
+ with open(path, "rb") as f:
409
+ tomllib.load(f)
410
+ return True
411
+ except (OSError, tomllib.TOMLDecodeError):
412
+ return False
413
+
414
+
415
+ def _toml_section_exists(config_text: str, section: str) -> bool:
416
+ pattern = rf"^\[{re.escape(section)}\]\s*$"
417
+ return re.search(pattern, config_text, flags=re.MULTILINE) is not None
418
+
419
+
420
+ def _remove_toml_section(config_text: str, section: str) -> str:
421
+ pattern = rf"(?ms)^\[{re.escape(section)}\]\n(?:.*\n)*?(?=^\[|\Z)"
422
+ return re.sub(pattern, "", config_text)
423
+
424
+
425
+ def is_aleph_configured_toml(client: ClientConfig) -> bool:
426
+ """Check if Aleph is configured in a TOML config file (Codex)."""
427
+ path = client.get_path()
428
+ if path is None or not path.exists():
429
+ return False
430
+ try:
431
+ config_text = path.read_text(encoding="utf-8")
432
+ except OSError:
433
+ return False
434
+ return _toml_section_exists(config_text, "mcp_servers.aleph")
435
+
436
+
437
+ def install_to_toml_config(
438
+ client: ClientConfig,
439
+ dry_run: bool = False,
440
+ ) -> bool:
441
+ """Install Aleph to a TOML config file (Codex)."""
442
+ path = client.get_path()
443
+ if path is None:
444
+ print_error(f"Could not determine config path for {client.display_name}")
445
+ return False
446
+
447
+ if path.exists():
448
+ try:
449
+ config_text = path.read_text(encoding="utf-8")
450
+ except OSError as e:
451
+ print_error(f"Could not read {path}: {e}")
452
+ return False
453
+ else:
454
+ config_text = ""
455
+
456
+ if _toml_section_exists(config_text, "mcp_servers.aleph"):
457
+ print_warning(f"Aleph is already configured in {client.display_name}")
458
+ return True
459
+
460
+ block = (
461
+ "[mcp_servers.aleph]\n"
462
+ "command = \"aleph\"\n"
463
+ "args = [\"--enable-actions\", \"--workspace-mode\", \"any\", \"--tool-docs\", \"concise\"]\n"
464
+ )
465
+
466
+ if dry_run:
467
+ print_info(f"[DRY RUN] Would write to: {path}")
468
+ print_info(f"[DRY RUN] Would append:\n{block}")
469
+ return True
470
+
471
+ path.parent.mkdir(parents=True, exist_ok=True)
472
+
473
+ if path.exists():
474
+ backup = backup_config_toml(path)
475
+ if backup:
476
+ print_info(f"Backed up existing config to: {backup}")
477
+
478
+ new_text = config_text
479
+ if new_text and not new_text.endswith("\n"):
480
+ new_text += "\n"
481
+ if new_text and not new_text.endswith("\n\n"):
482
+ new_text += "\n"
483
+ new_text += block
484
+
485
+ try:
486
+ path.write_text(new_text, encoding="utf-8")
487
+ except OSError as e:
488
+ print_error(f"Could not write to {path}: {e}")
489
+ return False
490
+
491
+ if not validate_toml(path):
492
+ print_error(f"Written TOML may be invalid! Check {path}")
493
+ return False
494
+
495
+ print_success(f"Configured Aleph in {client.display_name}")
496
+ print_info(f"Config file: {path}")
497
+ if client.restart_instruction:
498
+ print_info(client.restart_instruction)
499
+
500
+ return True
501
+
502
+
503
+ def uninstall_from_toml_config(
504
+ client: ClientConfig,
505
+ dry_run: bool = False,
506
+ ) -> bool:
507
+ """Remove Aleph from a TOML config file (Codex)."""
508
+ path = client.get_path()
509
+ if path is None:
510
+ print_error(f"Could not determine config path for {client.display_name}")
511
+ return False
512
+
513
+ if not path.exists():
514
+ print_warning(f"Config file does not exist: {path}")
515
+ return True
516
+
517
+ try:
518
+ config_text = path.read_text(encoding="utf-8")
519
+ except OSError as e:
520
+ print_error(f"Could not read {path}: {e}")
521
+ return False
522
+
523
+ has_main = _toml_section_exists(config_text, "mcp_servers.aleph")
524
+ has_env = _toml_section_exists(config_text, "mcp_servers.aleph.env")
525
+ if not has_main and not has_env:
526
+ print_warning(f"Aleph is not configured in {client.display_name}")
527
+ return True
528
+
529
+ if dry_run:
530
+ print_info(f"[DRY RUN] Would remove Aleph from: {path}")
531
+ return True
532
+
533
+ backup = backup_config_toml(path)
534
+ if backup:
535
+ print_info(f"Backed up existing config to: {backup}")
536
+
537
+ new_text = _remove_toml_section(config_text, "mcp_servers.aleph.env")
538
+ new_text = _remove_toml_section(new_text, "mcp_servers.aleph")
539
+ new_text = re.sub(r"\n{3,}", "\n\n", new_text).rstrip()
540
+ if new_text:
541
+ new_text += "\n"
542
+
543
+ try:
544
+ path.write_text(new_text, encoding="utf-8")
545
+ except OSError as e:
546
+ print_error(f"Could not write to {path}: {e}")
547
+ return False
548
+
549
+ print_success(f"Removed Aleph from {client.display_name}")
550
+ return True
551
+
552
+
553
+ def install_to_config_file(
554
+ client: ClientConfig,
555
+ dry_run: bool = False,
556
+ ) -> bool:
557
+ """Install Aleph to a JSON config file."""
558
+ path = client.get_path()
559
+ if path is None:
560
+ print_error(f"Could not determine config path for {client.display_name}")
561
+ return False
562
+
563
+ # Load existing config or create new
564
+ if path.exists():
565
+ try:
566
+ with open(path, "r", encoding="utf-8") as f:
567
+ config = json.load(f)
568
+ except json.JSONDecodeError as e:
569
+ print_error(f"Invalid JSON in {path}: {e}")
570
+ return False
571
+ except OSError as e:
572
+ print_error(f"Could not read {path}: {e}")
573
+ return False
574
+ else:
575
+ config = {}
576
+
577
+ # Ensure mcpServers exists
578
+ if "mcpServers" not in config:
579
+ config["mcpServers"] = {}
580
+
581
+ # Check if already configured
582
+ if "aleph" in config["mcpServers"]:
583
+ print_warning(f"Aleph is already configured in {client.display_name}")
584
+ return True
585
+
586
+ # Add Aleph config
587
+ config["mcpServers"]["aleph"] = ALEPH_MCP_CONFIG.copy()
588
+
589
+ if dry_run:
590
+ print_info(f"[DRY RUN] Would write to: {path}")
591
+ print_info(f"[DRY RUN] New config:\n{json.dumps(config, indent=2)}")
592
+ return True
593
+
594
+ # Create parent directory if needed
595
+ path.parent.mkdir(parents=True, exist_ok=True)
596
+
597
+ # Backup existing config
598
+ if path.exists():
599
+ backup = backup_config(path)
600
+ if backup:
601
+ print_info(f"Backed up existing config to: {backup}")
602
+
603
+ # Write new config
604
+ try:
605
+ with open(path, "w", encoding="utf-8") as f:
606
+ json.dump(config, f, indent=2)
607
+ except OSError as e:
608
+ print_error(f"Could not write to {path}: {e}")
609
+ return False
610
+
611
+ # Validate
612
+ if not validate_json(path):
613
+ print_error(f"Written JSON is invalid! Check {path}")
614
+ return False
615
+
616
+ print_success(f"Configured Aleph in {client.display_name}")
617
+ print_info(f"Config file: {path}")
618
+ if client.restart_instruction:
619
+ print_info(client.restart_instruction)
620
+
621
+ return True
622
+
623
+
624
+ def install_to_claude_code(dry_run: bool = False) -> bool:
625
+ """Install Aleph to Claude Code using CLI."""
626
+ claude_exe = _find_claude_cli()
627
+ if not claude_exe:
628
+ print_error("Claude Code CLI not found. Install it first: https://claude.ai/code")
629
+ if platform.system() == "Windows":
630
+ print_info("If installed via NPM, ensure %APPDATA%\\npm is in your PATH")
631
+ return False
632
+
633
+ if dry_run:
634
+ print_info(
635
+ f"[DRY RUN] Would run: {claude_exe} mcp add aleph aleph -- --enable-actions --workspace-mode any --tool-docs concise"
636
+ )
637
+ return True
638
+
639
+ try:
640
+ result = subprocess.run(
641
+ [
642
+ claude_exe,
643
+ "mcp",
644
+ "add",
645
+ "aleph",
646
+ "aleph",
647
+ "--",
648
+ "--enable-actions",
649
+ "--workspace-mode",
650
+ "any",
651
+ "--tool-docs",
652
+ "concise",
653
+ ],
654
+ capture_output=True,
655
+ text=True,
656
+ timeout=30,
657
+ shell=claude_exe.endswith((".cmd", ".ps1")), # Use shell for Windows scripts
658
+ )
659
+
660
+ if result.returncode == 0:
661
+ print_success("Configured Aleph in Claude Code")
662
+ print_info("Run 'claude' to use Aleph")
663
+ return True
664
+ else:
665
+ # Check if it's already installed
666
+ if "already exists" in result.stderr.lower():
667
+ print_warning("Aleph is already configured in Claude Code")
668
+ return True
669
+ print_error(f"Failed to add Aleph to Claude Code: {result.stderr}")
670
+ return False
671
+ except subprocess.TimeoutExpired:
672
+ print_error("Command timed out")
673
+ return False
674
+ except FileNotFoundError:
675
+ print_error("Claude Code CLI not found")
676
+ return False
677
+
678
+
679
+ def uninstall_from_config_file(
680
+ client: ClientConfig,
681
+ dry_run: bool = False,
682
+ ) -> bool:
683
+ """Remove Aleph from a JSON config file."""
684
+ path = client.get_path()
685
+ if path is None:
686
+ print_error(f"Could not determine config path for {client.display_name}")
687
+ return False
688
+
689
+ if not path.exists():
690
+ print_warning(f"Config file does not exist: {path}")
691
+ return True
692
+
693
+ try:
694
+ with open(path, "r", encoding="utf-8") as f:
695
+ config = json.load(f)
696
+ except (json.JSONDecodeError, OSError) as e:
697
+ print_error(f"Could not read {path}: {e}")
698
+ return False
699
+
700
+ if "mcpServers" not in config or "aleph" not in config["mcpServers"]:
701
+ print_warning(f"Aleph is not configured in {client.display_name}")
702
+ return True
703
+
704
+ if dry_run:
705
+ print_info(f"[DRY RUN] Would remove 'aleph' from mcpServers in: {path}")
706
+ return True
707
+
708
+ # Backup before removing
709
+ backup = backup_config(path)
710
+ if backup:
711
+ print_info(f"Backed up existing config to: {backup}")
712
+
713
+ # Remove Aleph
714
+ del config["mcpServers"]["aleph"]
715
+
716
+ # Clean up empty mcpServers
717
+ if not config["mcpServers"]:
718
+ del config["mcpServers"]
719
+
720
+ try:
721
+ with open(path, "w", encoding="utf-8") as f:
722
+ json.dump(config, f, indent=2)
723
+ except OSError as e:
724
+ print_error(f"Could not write to {path}: {e}")
725
+ return False
726
+
727
+ print_success(f"Removed Aleph from {client.display_name}")
728
+ return True
729
+
730
+
731
+ def uninstall_from_claude_code(dry_run: bool = False) -> bool:
732
+ """Remove Aleph from Claude Code using CLI."""
733
+ claude_exe = _find_claude_cli()
734
+ if not claude_exe:
735
+ print_error("Claude Code CLI not found")
736
+ return False
737
+
738
+ if dry_run:
739
+ print_info(f"[DRY RUN] Would run: {claude_exe} mcp remove aleph")
740
+ return True
741
+
742
+ try:
743
+ result = subprocess.run(
744
+ [claude_exe, "mcp", "remove", "aleph"],
745
+ capture_output=True,
746
+ text=True,
747
+ timeout=30,
748
+ shell=claude_exe.endswith((".cmd", ".ps1")), # Use shell for Windows scripts
749
+ )
750
+
751
+ if result.returncode == 0:
752
+ print_success("Removed Aleph from Claude Code")
753
+ return True
754
+ else:
755
+ if "not found" in result.stderr.lower():
756
+ print_warning("Aleph is not configured in Claude Code")
757
+ return True
758
+ print_error(f"Failed to remove Aleph from Claude Code: {result.stderr}")
759
+ return False
760
+ except subprocess.TimeoutExpired:
761
+ print_error("Command timed out")
762
+ return False
763
+ except FileNotFoundError:
764
+ print_error("Claude Code CLI not found")
765
+ return False
766
+
767
+
768
+ def install_client(client: ClientConfig, dry_run: bool = False) -> bool:
769
+ """Install Aleph to a specific client."""
770
+ if client.is_cli:
771
+ return install_to_claude_code(dry_run)
772
+ if client.config_format == "toml":
773
+ return install_to_toml_config(client, dry_run)
774
+ return install_to_config_file(client, dry_run)
775
+
776
+
777
+ def uninstall_client(client: ClientConfig, dry_run: bool = False) -> bool:
778
+ """Uninstall Aleph from a specific client."""
779
+ if client.is_cli:
780
+ return uninstall_from_claude_code(dry_run)
781
+ if client.config_format == "toml":
782
+ return uninstall_from_toml_config(client, dry_run)
783
+ return uninstall_from_config_file(client, dry_run)
784
+
785
+
786
+ # =============================================================================
787
+ # Doctor command
788
+ # =============================================================================
789
+
790
+ def doctor() -> bool:
791
+ """Verify Aleph installation and diagnose issues."""
792
+ print_header("Aleph Doctor")
793
+
794
+ all_ok = True
795
+
796
+ # Check if aleph is available
797
+ print_info("Checking aleph command...")
798
+ if shutil.which("aleph"):
799
+ print_success("aleph is in PATH")
800
+ else:
801
+ print_error("aleph not found in PATH")
802
+ print_info("Try reinstalling: pip install \"aleph-rlm[mcp]\"")
803
+ all_ok = False
804
+
805
+ # Check MCP dependency
806
+ print_info("\nChecking MCP dependency...")
807
+ try:
808
+ import mcp # noqa: F401
809
+ print_success("MCP package is installed")
810
+ except ImportError:
811
+ print_error("MCP package not installed")
812
+ print_info("Install with: pip install \"aleph-rlm[mcp]\"")
813
+ all_ok = False
814
+
815
+ # Check each client
816
+ print_info("\nChecking client configurations...")
817
+ rows = []
818
+
819
+ for name, client in CLIENTS.items():
820
+ if client.is_cli:
821
+ claude_exe = _find_claude_cli()
822
+ if claude_exe:
823
+ if is_aleph_configured(client):
824
+ status = "Configured"
825
+ else:
826
+ status = "Not configured"
827
+ path_str = f"(CLI: {claude_exe})"
828
+ else:
829
+ status = "Not installed"
830
+ path_str = "-"
831
+ else:
832
+ path = client.get_path()
833
+ if path is None:
834
+ status = "N/A"
835
+ path_str = "-"
836
+ elif not is_client_installed(client):
837
+ status = "Not installed"
838
+ path_str = str(path)
839
+ elif is_aleph_configured(client):
840
+ status = "Configured"
841
+ path_str = str(path)
842
+ else:
843
+ status = "Not configured"
844
+ path_str = str(path)
845
+
846
+ rows.append((client.display_name, status, path_str))
847
+
848
+ print_table("MCP Client Status", rows)
849
+
850
+ # Test MCP server startup
851
+ print_info("Testing MCP server startup...")
852
+ try:
853
+ from aleph.mcp.local_server import AlephMCPServerLocal # noqa: F401
854
+ print_success("Aleph MCP server module loads correctly")
855
+ except ImportError as e:
856
+ print_error(f"Failed to import MCP server: {e}")
857
+ all_ok = False
858
+ except RuntimeError as e:
859
+ if "mcp" in str(e).lower():
860
+ print_error(f"MCP dependency issue: {e}")
861
+ print_info("Install with: pip install \"aleph-rlm[mcp]\"")
862
+ else:
863
+ print_error(f"Server error: {e}")
864
+ all_ok = False
865
+
866
+ print()
867
+ if all_ok:
868
+ print_success("All checks passed!")
869
+ else:
870
+ print_error("Some checks failed. See above for details.")
871
+
872
+ return all_ok
873
+
874
+
875
+ # =============================================================================
876
+ # Interactive mode
877
+ # =============================================================================
878
+
879
+ def interactive_install(dry_run: bool = False) -> None:
880
+ """Interactive installation mode."""
881
+ print_header("Aleph MCP Server Installer")
882
+
883
+ # Detect installed clients
884
+ detected = []
885
+ for name, client in CLIENTS.items():
886
+ if is_client_installed(client):
887
+ configured = is_aleph_configured(client)
888
+ detected.append((name, client, configured))
889
+
890
+ if not detected:
891
+ print_warning("No MCP clients detected!")
892
+ print_info("Supported clients: Claude Desktop, Cursor, Windsurf, VSCode, Claude Code, Codex CLI")
893
+ return
894
+
895
+ print_info(f"Detected {len(detected)} MCP client(s):\n")
896
+
897
+ rows = []
898
+ for name, client, configured in detected:
899
+ status = "Already configured" if configured else "Not configured"
900
+ path = client.get_path()
901
+ path_str = "(CLI)" if client.is_cli else str(path) if path else "-"
902
+ rows.append((client.display_name, status, path_str))
903
+
904
+ print_table("Detected Clients", rows)
905
+
906
+ # Ask user which to configure
907
+ to_configure = []
908
+ for name, client, configured in detected:
909
+ if configured:
910
+ print_info(f"{client.display_name}: Already configured, skipping")
911
+ continue
912
+
913
+ try:
914
+ response = input(f"Configure {client.display_name}? [Y/n]: ").strip().lower()
915
+ if response in ("", "y", "yes"):
916
+ to_configure.append(client)
917
+ except (EOFError, KeyboardInterrupt):
918
+ print("\nAborted.")
919
+ return
920
+
921
+ if not to_configure:
922
+ print_info("No clients to configure.")
923
+ return
924
+
925
+ print()
926
+ for client in to_configure:
927
+ install_client(client, dry_run)
928
+ print()
929
+
930
+
931
+ def install_all(dry_run: bool = False) -> None:
932
+ """Install Aleph to all detected clients."""
933
+ print_header("Installing Aleph to All Detected Clients")
934
+
935
+ for name, client in CLIENTS.items():
936
+ if not is_client_installed(client):
937
+ print_info(f"Skipping {client.display_name} (not installed)")
938
+ continue
939
+
940
+ if is_aleph_configured(client):
941
+ print_info(f"Skipping {client.display_name} (already configured)")
942
+ continue
943
+
944
+ install_client(client, dry_run)
945
+ print()
946
+
947
+
948
+ # =============================================================================
949
+ # CLI entry point
950
+ # =============================================================================
951
+
952
+ def print_usage() -> None:
953
+ """Print CLI usage information."""
954
+ print("""
955
+ Aleph MCP Server Installer
956
+
957
+ Usage:
958
+ aleph-rlm install Interactive mode - detect and configure clients
959
+ aleph-rlm install <client> Configure a specific client
960
+ aleph-rlm install --all Configure all detected clients
961
+ aleph-rlm uninstall <client> Remove Aleph from a client
962
+ aleph-rlm doctor Verify installation
963
+
964
+ Clients:
965
+ claude-desktop Claude Desktop app
966
+ cursor Cursor editor (global config)
967
+ cursor-project Cursor editor (project config)
968
+ windsurf Windsurf editor
969
+ vscode VSCode (project config)
970
+ claude-code Claude Code CLI
971
+ codex Codex CLI
972
+ gemini Gemini CLI
973
+
974
+ Options:
975
+ --dry-run Preview changes without writing
976
+ --help, -h Show this help message
977
+
978
+ Examples:
979
+ aleph-rlm install # Interactive mode
980
+ aleph-rlm install claude-desktop # Configure Claude Desktop
981
+ aleph-rlm install codex # Configure Codex CLI
982
+ aleph-rlm install --all --dry-run # Preview all installations
983
+ aleph-rlm uninstall cursor # Remove from Cursor
984
+ aleph-rlm doctor # Check installation status
985
+ """)
986
+
987
+
988
+ def main() -> None:
989
+ """CLI entry point."""
990
+ args = sys.argv[1:]
991
+
992
+ if not args or args[0] in ("--help", "-h", "help"):
993
+ print_usage()
994
+ return
995
+
996
+ dry_run = "--dry-run" in args
997
+ if dry_run:
998
+ args = [a for a in args if a != "--dry-run"]
999
+
1000
+ command = args[0] if args else ""
1001
+
1002
+ if command == "doctor":
1003
+ success = doctor()
1004
+ sys.exit(0 if success else 1)
1005
+
1006
+ elif command == "install":
1007
+ if len(args) == 1:
1008
+ # Interactive mode
1009
+ interactive_install(dry_run)
1010
+ elif args[1] == "--all":
1011
+ install_all(dry_run)
1012
+ elif args[1] in CLIENTS:
1013
+ client = CLIENTS[args[1]]
1014
+ success = install_client(client, dry_run)
1015
+ sys.exit(0 if success else 1)
1016
+ else:
1017
+ print_error(f"Unknown client: {args[1]}")
1018
+ print_info(f"Available clients: {', '.join(CLIENTS.keys())}")
1019
+ sys.exit(1)
1020
+
1021
+ elif command == "uninstall":
1022
+ if len(args) < 2:
1023
+ print_error("Please specify a client to uninstall from")
1024
+ print_info(f"Available clients: {', '.join(CLIENTS.keys())}")
1025
+ sys.exit(1)
1026
+
1027
+ client_name = args[1]
1028
+ if client_name not in CLIENTS:
1029
+ print_error(f"Unknown client: {client_name}")
1030
+ print_info(f"Available clients: {', '.join(CLIENTS.keys())}")
1031
+ sys.exit(1)
1032
+
1033
+ client = CLIENTS[client_name]
1034
+ success = uninstall_client(client, dry_run)
1035
+ sys.exit(0 if success else 1)
1036
+
1037
+ else:
1038
+ print_error(f"Unknown command: {command}")
1039
+ print_usage()
1040
+ sys.exit(1)
1041
+
1042
+
1043
+ if __name__ == "__main__":
1044
+ main()