bingo-light 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,549 @@
1
+ """
2
+ bingo_core.setup — Interactive setup wizard for configuring MCP across AI tools.
3
+
4
+ Detects installed AI coding tools, presents a multi-select menu, and writes
5
+ the correct MCP server configuration for each selected tool.
6
+
7
+ Python 3.8+ stdlib only. Uses termios for raw input on Unix.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import platform
15
+ import shutil
16
+ import sys
17
+ from dataclasses import dataclass, field
18
+ from typing import Any, Dict, List, Optional, Tuple
19
+
20
+
21
+ # ─── AI Tool Definitions ────────────────────────────────────────────────────
22
+
23
+
24
+ @dataclass
25
+ class AITool:
26
+ """Describes one AI coding tool that supports MCP."""
27
+
28
+ id: str
29
+ name: str
30
+ config_path: str # with ~ or env vars, expanded at runtime
31
+ top_key: str # JSON key wrapping the server entry
32
+ detect_dirs: List[str] = field(default_factory=list)
33
+ detect_cmds: List[str] = field(default_factory=list)
34
+ extra_fields: Dict[str, Any] = field(default_factory=dict)
35
+ note: str = ""
36
+
37
+ def expand_path(self) -> str:
38
+ return os.path.expandvars(os.path.expanduser(self.config_path))
39
+
40
+ def is_detected(self) -> bool:
41
+ for d in self.detect_dirs:
42
+ if os.path.isdir(os.path.expanduser(d)):
43
+ return True
44
+ for c in self.detect_cmds:
45
+ if shutil.which(c):
46
+ return True
47
+ return False
48
+
49
+
50
+ def _get_tools() -> List[AITool]:
51
+ """Return the list of all supported AI tools."""
52
+ is_mac = platform.system() == "Darwin"
53
+ is_win = platform.system() == "Windows"
54
+
55
+ tools = [
56
+ AITool(
57
+ id="claude-code",
58
+ name="Claude Code",
59
+ config_path="~/.claude/settings.json",
60
+ top_key="mcpServers",
61
+ detect_dirs=["~/.claude"],
62
+ detect_cmds=["claude"],
63
+ ),
64
+ AITool(
65
+ id="claude-desktop",
66
+ name="Claude Desktop",
67
+ config_path=(
68
+ "~/Library/Application Support/Claude/claude_desktop_config.json"
69
+ if is_mac
70
+ else "%APPDATA%\\Claude\\claude_desktop_config.json"
71
+ if is_win
72
+ else ""
73
+ ),
74
+ top_key="mcpServers",
75
+ detect_dirs=(
76
+ ["~/Library/Application Support/Claude"] if is_mac
77
+ else ["%APPDATA%\\Claude"] if is_win
78
+ else []
79
+ ),
80
+ ),
81
+ AITool(
82
+ id="cursor",
83
+ name="Cursor",
84
+ config_path="~/.cursor/mcp.json",
85
+ top_key="mcpServers",
86
+ detect_dirs=["~/.cursor"],
87
+ detect_cmds=["cursor"],
88
+ ),
89
+ AITool(
90
+ id="windsurf",
91
+ name="Windsurf",
92
+ config_path="~/.codeium/windsurf/mcp_config.json",
93
+ top_key="mcpServers",
94
+ detect_dirs=["~/.codeium/windsurf"],
95
+ ),
96
+ AITool(
97
+ id="vscode-copilot",
98
+ name="VS Code / Copilot",
99
+ config_path="~/.vscode/mcp.json",
100
+ top_key="servers",
101
+ detect_dirs=["~/.vscode"],
102
+ detect_cmds=["code"],
103
+ extra_fields={"type": "stdio"},
104
+ note="Also supports .vscode/mcp.json per project",
105
+ ),
106
+ AITool(
107
+ id="cline",
108
+ name="Cline",
109
+ config_path="~/.vscode/cline_mcp_settings.json",
110
+ top_key="mcpServers",
111
+ detect_dirs=["~/.vscode"],
112
+ note="Managed by Cline extension; edit via MCP Servers panel",
113
+ ),
114
+ AITool(
115
+ id="roo-code",
116
+ name="Roo Code",
117
+ config_path="~/.vscode/roo_mcp_settings.json",
118
+ top_key="mcpServers",
119
+ detect_dirs=["~/.vscode"],
120
+ note="Managed by Roo Code extension; edit via settings",
121
+ ),
122
+ AITool(
123
+ id="zed",
124
+ name="Zed",
125
+ config_path="~/.config/zed/settings.json",
126
+ top_key="context_servers",
127
+ detect_dirs=["~/.config/zed"],
128
+ detect_cmds=["zed"],
129
+ ),
130
+ AITool(
131
+ id="gemini-cli",
132
+ name="Gemini CLI",
133
+ config_path="~/.gemini/settings.json",
134
+ top_key="mcpServers",
135
+ detect_dirs=["~/.gemini"],
136
+ detect_cmds=["gemini"],
137
+ ),
138
+ AITool(
139
+ id="amazon-q",
140
+ name="Amazon Q Developer",
141
+ config_path="~/.aws/amazonq/mcp.json",
142
+ top_key="mcpServers",
143
+ detect_dirs=["~/.aws/amazonq"],
144
+ ),
145
+ ]
146
+
147
+ # Filter out tools with empty config paths (e.g. Claude Desktop on Linux)
148
+ return [t for t in tools if t.config_path]
149
+
150
+
151
+ # ─── MCP Server Path Detection ──────────────────────────────────────────────
152
+
153
+
154
+ def find_mcp_server() -> Tuple[str, List[str]]:
155
+ """Find the MCP server command and args.
156
+
157
+ Returns (command, args) tuple. Tries:
158
+ 1. Installed `bingo-light-mcp` on PATH
159
+ 2. mcp-server.py next to the running script
160
+ 3. mcp-server.py next to bingo_core package
161
+ """
162
+ # 1. Installed binary on PATH
163
+ for name in ("bingo-light-mcp", "mcp-server.py"):
164
+ mcp_bin = shutil.which(name)
165
+ if mcp_bin:
166
+ return ("python3", [mcp_bin])
167
+
168
+ # 2. Relative to running script
169
+ script_dir = os.path.dirname(os.path.abspath(sys.argv[0]))
170
+ candidate = os.path.join(script_dir, "mcp-server.py")
171
+ if os.path.isfile(candidate):
172
+ return ("python3", [candidate])
173
+
174
+ # 3. Relative to bingo_core package
175
+ pkg_dir = os.path.dirname(os.path.abspath(__file__))
176
+ candidate = os.path.join(os.path.dirname(pkg_dir), "mcp-server.py")
177
+ if os.path.isfile(candidate):
178
+ return ("python3", [candidate])
179
+
180
+ # Fallback
181
+ return ("python3", ["bingo-light-mcp"])
182
+
183
+
184
+ # ─── Config Writer ───────────────────────────────────────────────────────────
185
+
186
+
187
+ def write_mcp_config(
188
+ tool: AITool,
189
+ command: str,
190
+ args: List[str],
191
+ server_name: str = "bingo-light",
192
+ ) -> Dict[str, Any]:
193
+ """Write MCP server config for one AI tool.
194
+
195
+ Returns dict with ok, tool, config_path, action (created|updated|error).
196
+ """
197
+ config_path = tool.expand_path()
198
+ config_dir = os.path.dirname(config_path)
199
+
200
+ try:
201
+ os.makedirs(config_dir, exist_ok=True)
202
+ except OSError as e:
203
+ return {"ok": False, "tool": tool.id, "error": str(e)}
204
+
205
+ # Read existing config
206
+ data: Dict[str, Any] = {}
207
+ if os.path.isfile(config_path):
208
+ try:
209
+ with open(config_path) as f:
210
+ data = json.load(f)
211
+ action = "updated"
212
+ except (json.JSONDecodeError, OSError):
213
+ data = {}
214
+ action = "created"
215
+ else:
216
+ action = "created"
217
+
218
+ # Build the server entry
219
+ entry: Dict[str, Any] = {"command": command, "args": args}
220
+ entry.update(tool.extra_fields)
221
+
222
+ # Write under the correct top-level key
223
+ servers = data.setdefault(tool.top_key, {})
224
+ already = server_name in servers
225
+ servers[server_name] = entry
226
+
227
+ try:
228
+ with open(config_path, "w") as f:
229
+ json.dump(data, f, indent=2)
230
+ f.write("\n")
231
+ except OSError as e:
232
+ return {"ok": False, "tool": tool.id, "error": str(e)}
233
+
234
+ return {
235
+ "ok": True,
236
+ "tool": tool.id,
237
+ "name": tool.name,
238
+ "config_path": config_path,
239
+ "action": "unchanged" if already else action,
240
+ }
241
+
242
+
243
+ # ─── Interactive Multi-Select ────────────────────────────────────────────────
244
+
245
+
246
+ def _tildify(path: str) -> str:
247
+ home = os.path.expanduser("~")
248
+ if path.startswith(home + os.sep):
249
+ return "~" + path[len(home):]
250
+ if path == home:
251
+ return "~"
252
+ return path
253
+
254
+
255
+ def _read_key() -> str:
256
+ """Read a single keypress (including escape sequences) from /dev/tty."""
257
+ import termios
258
+ import tty
259
+
260
+ fd = sys.stdin.fileno()
261
+ old = termios.tcgetattr(fd)
262
+ try:
263
+ tty.setraw(fd)
264
+ ch = sys.stdin.read(1)
265
+ if ch == "\x1b":
266
+ ch2 = sys.stdin.read(1)
267
+ if ch2 == "[":
268
+ ch3 = sys.stdin.read(1)
269
+ return f"\x1b[{ch3}"
270
+ return ch + ch2
271
+ return ch
272
+ finally:
273
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
274
+
275
+
276
+ def _read_key_tty() -> str:
277
+ """Read a keypress from /dev/tty (works even when stdin is piped)."""
278
+ import termios
279
+ import tty
280
+
281
+ try:
282
+ fd = os.open("/dev/tty", os.O_RDONLY)
283
+ except OSError:
284
+ return _read_key()
285
+
286
+ old = termios.tcgetattr(fd)
287
+ try:
288
+ tty.setraw(fd)
289
+ ch = os.read(fd, 1).decode("utf-8", errors="replace")
290
+ if ch == "\x1b":
291
+ ch2 = os.read(fd, 1).decode("utf-8", errors="replace")
292
+ if ch2 == "[":
293
+ ch3 = os.read(fd, 1).decode("utf-8", errors="replace")
294
+ return f"\x1b[{ch3}"
295
+ return ch + ch2
296
+ return ch
297
+ finally:
298
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
299
+ os.close(fd)
300
+
301
+
302
+ def multiselect(
303
+ items: List[Dict[str, Any]],
304
+ pre_selected: Optional[List[int]] = None,
305
+ ) -> List[int]:
306
+ """Interactive multi-select with arrow keys and spacebar.
307
+
308
+ items: list of {"label": str, "hint": str, "detected": bool}
309
+ pre_selected: indices that start checked
310
+
311
+ Returns list of selected indices.
312
+ """
313
+ out = sys.stderr
314
+ selected = set(pre_selected or [])
315
+ cursor = 0
316
+ n = len(items)
317
+
318
+ def render():
319
+ for i, item in enumerate(items):
320
+ prefix = " >" if i == cursor else " "
321
+ check = "\033[32m[x]\033[0m" if i in selected else "[ ]"
322
+ label = item["label"]
323
+ hint = item.get("hint", "")
324
+ detected = item.get("detected", False)
325
+
326
+ if i == cursor:
327
+ label = f"\033[1m{label}\033[0m"
328
+
329
+ if hint:
330
+ if detected:
331
+ hint_str = f" \033[2m{hint}\033[0m"
332
+ else:
333
+ hint_str = " \033[2m(not detected)\033[0m"
334
+ else:
335
+ hint_str = ""
336
+
337
+ out.write(f"{prefix} {check} {label}{hint_str}\n")
338
+
339
+ def clear():
340
+ for _ in range(n):
341
+ out.write("\033[1A\033[2K")
342
+
343
+ # Hide cursor
344
+ out.write("\033[?25l")
345
+ out.flush()
346
+
347
+ try:
348
+ render()
349
+ out.flush()
350
+
351
+ while True:
352
+ key = _read_key_tty()
353
+
354
+ if key == "\x1b[A": # Up
355
+ cursor = (cursor - 1) % n
356
+ elif key == "\x1b[B": # Down
357
+ cursor = (cursor + 1) % n
358
+ elif key == " ": # Space = toggle
359
+ if cursor in selected:
360
+ selected.discard(cursor)
361
+ else:
362
+ selected.add(cursor)
363
+ elif key == "a": # Toggle all
364
+ if len(selected) == n:
365
+ selected.clear()
366
+ else:
367
+ selected = set(range(n))
368
+ elif key in ("\r", "\n"): # Enter = confirm
369
+ clear()
370
+ # Show final state (non-interactive)
371
+ for i, item in enumerate(items):
372
+ check = "\033[32m[x]\033[0m" if i in selected else "\033[2m[ ]\033[0m"
373
+ label = item["label"]
374
+ out.write(f" {check} {label}\n")
375
+ out.flush()
376
+ return sorted(selected)
377
+ elif key in ("\x03", "\x04"): # Ctrl-C / Ctrl-D
378
+ clear()
379
+ out.write("\033[?25h")
380
+ out.flush()
381
+ raise KeyboardInterrupt
382
+
383
+ clear()
384
+ render()
385
+ out.flush()
386
+ finally:
387
+ out.write("\033[?25h")
388
+ out.flush()
389
+
390
+
391
+ # ─── Shell Completions ───────────────────────────────────────────────────────
392
+
393
+
394
+ def detect_shell() -> str:
395
+ """Detect the user's shell."""
396
+ return os.path.basename(os.environ.get("SHELL", "sh"))
397
+
398
+
399
+ def install_completions(shell: str, source_dir: str) -> Dict[str, Any]:
400
+ """Install shell completions. Returns result dict."""
401
+ home = os.path.expanduser("~")
402
+
403
+ if shell == "bash":
404
+ xdg = os.environ.get("XDG_DATA_HOME", os.path.join(home, ".local", "share"))
405
+ comp_dir = os.environ.get(
406
+ "BASH_COMPLETION_USER_DIR",
407
+ os.path.join(xdg, "bash-completion", "completions"),
408
+ )
409
+ src = os.path.join(source_dir, "completions", "bingo-light.bash")
410
+ dst = os.path.join(comp_dir, "bingo-light")
411
+ elif shell == "zsh":
412
+ comp_dir = os.path.join(home, ".zfunc")
413
+ src = os.path.join(source_dir, "completions", "bingo-light.zsh")
414
+ dst = os.path.join(comp_dir, "_bingo-light")
415
+ elif shell == "fish":
416
+ comp_dir = os.path.join(home, ".config", "fish", "completions")
417
+ src = os.path.join(source_dir, "completions", "bingo-light.fish")
418
+ dst = os.path.join(comp_dir, "bingo-light.fish")
419
+ else:
420
+ return {"ok": False, "shell": shell, "error": f"Unsupported shell: {shell}"}
421
+
422
+ if not os.path.isfile(src):
423
+ return {"ok": False, "shell": shell, "error": f"Completion file not found: {src}"}
424
+
425
+ try:
426
+ os.makedirs(comp_dir, exist_ok=True)
427
+ shutil.copy2(src, dst)
428
+ except OSError as e:
429
+ return {"ok": False, "shell": shell, "error": str(e)}
430
+
431
+ # For zsh, ensure fpath is set in .zshrc
432
+ if shell == "zsh":
433
+ zshrc = os.path.join(home, ".zshrc")
434
+ if os.path.isfile(zshrc):
435
+ with open(zshrc) as f:
436
+ content = f.read()
437
+ if ".zfunc" not in content:
438
+ with open(zshrc, "a") as f:
439
+ f.write("\nfpath=(~/.zfunc $fpath)\nautoload -Uz compinit && compinit\n")
440
+
441
+ return {"ok": True, "shell": shell, "path": dst}
442
+
443
+
444
+ # ─── Main Setup Flow ────────────────────────────────────────────────────────
445
+
446
+
447
+ def run_setup(
448
+ yes: bool = False,
449
+ json_mode: bool = False,
450
+ no_completions: bool = False,
451
+ ) -> Dict[str, Any]:
452
+ """Run the interactive setup wizard.
453
+
454
+ Returns a result dict summarizing what was configured.
455
+ """
456
+ out = sys.stderr
457
+ tools = _get_tools()
458
+ command, args = find_mcp_server()
459
+ results: List[Dict[str, Any]] = []
460
+ is_tty = hasattr(sys.stderr, "isatty") and sys.stderr.isatty()
461
+
462
+ # ── Header ──
463
+ if not json_mode and is_tty:
464
+ out.write("\n \033[1mbingo-light setup\033[0m\n")
465
+ out.write(" \033[2mConfigure MCP server for your AI tools\033[0m\n\n")
466
+ out.write(f" MCP server: \033[2m{command} {' '.join(args)}\033[0m\n\n")
467
+
468
+ # ── Detect tools ──
469
+ items = []
470
+ detected_indices = []
471
+ for i, tool in enumerate(tools):
472
+ detected = tool.is_detected()
473
+ items.append({
474
+ "label": tool.name,
475
+ "hint": _tildify(tool.expand_path()),
476
+ "detected": detected,
477
+ })
478
+ if detected:
479
+ detected_indices.append(i)
480
+
481
+ # ── Select tools ──
482
+ if yes:
483
+ # Non-interactive: configure all detected tools
484
+ selected = detected_indices
485
+ if not json_mode and is_tty:
486
+ out.write(" Auto-configuring detected tools:\n")
487
+ for i in selected:
488
+ out.write(f" \033[32m[x]\033[0m {tools[i].name}\n")
489
+ out.write("\n")
490
+ elif is_tty:
491
+ # Interactive multi-select
492
+ out.write(" Select tools to configure:\n")
493
+ out.write(" \033[2m↑/↓ navigate SPACE select a toggle all ENTER confirm\033[0m\n\n")
494
+ selected = multiselect(items, pre_selected=detected_indices)
495
+ out.write("\n")
496
+ else:
497
+ # Non-TTY fallback: configure detected tools
498
+ selected = detected_indices
499
+
500
+ if not selected:
501
+ if not json_mode and is_tty:
502
+ out.write(" No tools selected.\n\n")
503
+ return {"ok": True, "configured": [], "skipped": True}
504
+
505
+ # ── Configure each selected tool ──
506
+ for idx in selected:
507
+ tool = tools[idx]
508
+ result = write_mcp_config(tool, command, args)
509
+ results.append(result)
510
+
511
+ if not json_mode and is_tty:
512
+ if result["ok"]:
513
+ path = _tildify(result["config_path"])
514
+ out.write(f" \033[32m✓\033[0m {tool.name} → {path}\n")
515
+ else:
516
+ out.write(f" \033[31mx\033[0m {tool.name}: {result.get('error', 'unknown error')}\n")
517
+
518
+ # ── Shell completions ──
519
+ if not no_completions:
520
+ shell = detect_shell()
521
+ # Find source dir (completions/ alongside bingo-light script)
522
+ script_dir = os.path.dirname(os.path.abspath(sys.argv[0]))
523
+ pkg_dir = os.path.dirname(os.path.abspath(__file__))
524
+ source_dir = script_dir if os.path.isdir(os.path.join(script_dir, "completions")) else os.path.dirname(pkg_dir)
525
+
526
+ if os.path.isdir(os.path.join(source_dir, "completions")):
527
+ comp_result = install_completions(shell, source_dir)
528
+ if not json_mode and is_tty:
529
+ if comp_result["ok"]:
530
+ out.write(f" \033[32m✓\033[0m {shell} completions installed\n")
531
+ else:
532
+ out.write(f" \033[2m⊘ {shell} completions: {comp_result.get('error', 'skipped')}\033[0m\n")
533
+
534
+ # ── Summary ──
535
+ configured = [r for r in results if r["ok"]]
536
+ failed = [r for r in results if not r["ok"]]
537
+
538
+ if not json_mode and is_tty:
539
+ out.write(f"\n \033[1m\033[32m✓ {len(configured)} tool(s) configured\033[0m")
540
+ if failed:
541
+ out.write(f", \033[31m{len(failed)} failed\033[0m")
542
+ out.write("\n\n")
543
+
544
+ return {
545
+ "ok": len(failed) == 0,
546
+ "configured": [r["tool"] for r in configured],
547
+ "failed": [r["tool"] for r in failed],
548
+ "results": results,
549
+ }