mcpswitch-cli 0.1.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.
mcpswitch/cli.py ADDED
@@ -0,0 +1,1289 @@
1
+ """MCPSwitch CLI -- smart MCP profile manager for Claude Code."""
2
+
3
+ import sys
4
+ import os
5
+ import click
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+ from rich.panel import Panel
9
+ from rich import box
10
+
11
+ from .config import (
12
+ get_claude_code_config_path,
13
+ get_claude_desktop_config_path,
14
+ get_all_mcp_servers,
15
+ set_mcp_servers,
16
+ )
17
+ from .profiles import (
18
+ create_profile,
19
+ get_profile,
20
+ delete_profile,
21
+ list_profiles,
22
+ load_profiles,
23
+ get_active_profile,
24
+ set_active_profile,
25
+ add_server_to_profile,
26
+ remove_server_from_profile,
27
+ )
28
+ from .tokens import (
29
+ estimate_total_tokens,
30
+ format_token_count,
31
+ clear_cache,
32
+ list_cache,
33
+ )
34
+ from .hooks import install_hooks, uninstall_hooks
35
+ from .auto import auto_switch, select_best_profile, CONFIDENCE_THRESHOLD
36
+ from .usage import (
37
+ get_server_usage_stats,
38
+ get_waste_report,
39
+ get_usage_summary,
40
+ get_top_tools_by_server,
41
+ )
42
+ from .tier import require_pro, is_pro, maybe_show_donation_nudge
43
+
44
+ if sys.platform == "win32":
45
+ os.environ.setdefault("PYTHONUTF8", "1")
46
+ try:
47
+ sys.stdout.reconfigure(encoding="utf-8")
48
+ sys.stderr.reconfigure(encoding="utf-8")
49
+ except Exception:
50
+ pass
51
+
52
+ console = Console(highlight=False)
53
+ TARGET_CHOICES = ["code", "desktop", "both"]
54
+
55
+ SOURCE_LABELS = {
56
+ "cache": "[dim]cached[/dim]",
57
+ "known": "[dim]known[/dim]",
58
+ "live": "[green]live[/green]",
59
+ "fallback": "[yellow]fallback[/yellow]",
60
+ }
61
+
62
+
63
+ def _get_config_path(target: str):
64
+ if target == "code":
65
+ return get_claude_code_config_path()
66
+ elif target == "desktop":
67
+ path = get_claude_desktop_config_path()
68
+ if path is None:
69
+ console.print("[red]Claude Desktop config not found on this platform.[/red]")
70
+ sys.exit(1)
71
+ return path
72
+ return None
73
+
74
+
75
+ @click.group()
76
+ @click.version_option("0.1.0", prog_name="mcpswitch")
77
+ def cli():
78
+ """MCPSwitch -- smart MCP profile manager for Claude Code.
79
+
80
+ Save 30-40% of your context window by loading only the MCPs you need.
81
+ Works with any MCP server -- known or unknown.
82
+ """
83
+
84
+
85
+ # ─── STATUS ──────────────────────────────────────────────────────────────────
86
+
87
+ @cli.command()
88
+ @click.option("--target", default="code", type=click.Choice(TARGET_CHOICES))
89
+ @click.option("--live", is_flag=True, help="Query each server live for exact token counts")
90
+ def status(target, live):
91
+ """Show active profile and current token cost."""
92
+ path = _get_config_path(target)
93
+ servers = get_all_mcp_servers(path)
94
+ active = get_active_profile()
95
+
96
+ if not servers:
97
+ console.print(Panel(
98
+ "[yellow]No MCP servers configured.[/yellow]\n"
99
+ "Run [bold]mcpswitch import --name <name>[/bold] to create a profile.",
100
+ title="MCPSwitch Status"
101
+ ))
102
+ return
103
+
104
+ if live:
105
+ console.print("[dim]Querying servers live... (this may take a few seconds)[/dim]")
106
+
107
+ est = estimate_total_tokens(servers, live=live)
108
+ pct = est["context_pct"]
109
+ color = "green" if pct < 5 else "yellow" if pct < 15 else "red"
110
+
111
+ lines = [
112
+ f"Active profile : [bold]{active or '[none]'}[/bold]",
113
+ f"Servers loaded : [bold]{len(servers)}[/bold]",
114
+ f"Token overhead : [bold {color}]{format_token_count(est['total'])}[/bold {color}] "
115
+ f"([{color}]{pct}% of context window[/{color}])",
116
+ f"Config : [dim]{path}[/dim]",
117
+ ]
118
+
119
+ table = Table(box=box.SIMPLE, show_header=True, header_style="bold dim")
120
+ table.add_column("Server", style="cyan")
121
+ table.add_column("Est. Tokens", justify="right")
122
+ table.add_column("% of Budget", justify="right")
123
+ table.add_column("Source", justify="center")
124
+
125
+ for name, info in sorted(est["per_server"].items(), key=lambda x: -x[1]["tokens"]):
126
+ tok = info["tokens"]
127
+ src = info["source"]
128
+ pct_s = round((tok / est["total"]) * 100) if est["total"] > 0 else 0
129
+ table.add_row(name, format_token_count(tok), f"{pct_s}%", SOURCE_LABELS.get(src, src))
130
+
131
+ console.print(Panel("\n".join(lines), title="[bold cyan]MCPSwitch Status[/bold cyan]"))
132
+ console.print(table)
133
+
134
+
135
+ # ─── LIST ─────────────────────────────────────────────────────────────────────
136
+
137
+ @cli.command(name="list")
138
+ def list_cmd():
139
+ """List all saved profiles with token estimates."""
140
+ names = list_profiles()
141
+ active = get_active_profile()
142
+
143
+ if not names:
144
+ console.print("[yellow]No profiles yet. Run [bold]mcpswitch import --name <name>[/bold] to create one.[/yellow]")
145
+ return
146
+
147
+ all_profiles = load_profiles()
148
+
149
+ table = Table(title="MCP Profiles", box=box.ROUNDED)
150
+ table.add_column("Profile", style="bold")
151
+ table.add_column("Servers", justify="center")
152
+ table.add_column("Est. Tokens", justify="right")
153
+ table.add_column("Context %", justify="right")
154
+ table.add_column("Active", justify="center")
155
+
156
+ for name in names:
157
+ servers = all_profiles[name]
158
+ est = estimate_total_tokens(servers)
159
+ pct = est["context_pct"]
160
+ color = "green" if pct < 5 else "yellow" if pct < 15 else "red"
161
+ is_active = "[bold green]yes[/bold green]" if name == active else ""
162
+ table.add_row(
163
+ name,
164
+ str(len(servers)),
165
+ format_token_count(est["total"]),
166
+ f"[{color}]{pct}%[/{color}]",
167
+ is_active,
168
+ )
169
+
170
+ console.print(table)
171
+
172
+
173
+ # ─── USE ──────────────────────────────────────────────────────────────────────
174
+
175
+ @cli.command()
176
+ @click.argument("profile_name")
177
+ @click.option("--target", default="both", type=click.Choice(TARGET_CHOICES))
178
+ def use(profile_name, target):
179
+ """Switch to a profile -- rewrites your Claude MCP config."""
180
+ servers = get_profile(profile_name)
181
+ if servers is None:
182
+ console.print(f"[red]Profile '{profile_name}' not found.[/red] Run [bold]mcpswitch list[/bold].")
183
+ sys.exit(1)
184
+
185
+ targets = []
186
+ if target in ("code", "both"):
187
+ targets.append(get_claude_code_config_path())
188
+ if target in ("desktop", "both"):
189
+ p = get_claude_desktop_config_path()
190
+ if p and p.exists():
191
+ targets.append(p)
192
+
193
+ est = estimate_total_tokens(servers)
194
+
195
+ for path in targets:
196
+ set_mcp_servers(path, servers)
197
+ console.print(f"[green]Updated[/green] {path}")
198
+
199
+ set_active_profile(profile_name)
200
+
201
+ pct = est["context_pct"]
202
+ color = "green" if pct < 5 else "yellow" if pct < 15 else "red"
203
+ console.print(
204
+ f"\n[bold green]Switched to '{profile_name}'[/bold green] -- "
205
+ f"[{color}]{format_token_count(est['total'])} tokens ({pct}% of context)[/{color}]"
206
+ )
207
+ console.print("[dim]Restart Claude Code for changes to take effect.[/dim]")
208
+
209
+
210
+ # ─── IMPORT ───────────────────────────────────────────────────────────────────
211
+
212
+ @cli.command(name="import")
213
+ @click.option("--name", required=True, prompt="Profile name")
214
+ @click.option("--target", default="code", type=click.Choice(["code", "desktop"]))
215
+ def import_cmd(name, target):
216
+ """Import current MCP config as a named profile."""
217
+ path = _get_config_path(target)
218
+ servers = get_all_mcp_servers(path)
219
+
220
+ if not servers:
221
+ console.print(f"[yellow]No MCP servers found in {path}[/yellow]")
222
+ return
223
+
224
+ create_profile(name, servers)
225
+ est = estimate_total_tokens(servers)
226
+ console.print(
227
+ f"[green]Saved[/green] profile '[bold]{name}[/bold]' "
228
+ f"with {len(servers)} server(s) "
229
+ f"({format_token_count(est['total'])} tokens, {est['context_pct']}% of context)"
230
+ )
231
+
232
+
233
+ # ─── CREATE ───────────────────────────────────────────────────────────────────
234
+
235
+ @cli.command()
236
+ @click.argument("profile_name")
237
+ def create(profile_name):
238
+ """Create a new empty profile."""
239
+ create_profile(profile_name, {})
240
+ console.print(f"[green]Created[/green] empty profile '[bold]{profile_name}[/bold]'")
241
+ console.print(f"Add servers: [bold]mcpswitch add {profile_name} <server-name>[/bold]")
242
+
243
+
244
+ # ─── ADD ──────────────────────────────────────────────────────────────────────
245
+
246
+ @cli.command()
247
+ @click.argument("profile_name")
248
+ @click.argument("server_name")
249
+ @click.option("--from-target", default="code", type=click.Choice(["code", "desktop"]))
250
+ def add(profile_name, server_name, from_target):
251
+ """Add a server from your current config into a profile."""
252
+ path = _get_config_path(from_target)
253
+ all_servers = get_all_mcp_servers(path)
254
+
255
+ if server_name not in all_servers:
256
+ console.print(f"[red]Server '{server_name}' not found in {path}[/red]")
257
+ avail = ", ".join(all_servers.keys()) or "none"
258
+ console.print(f"Available: {avail}")
259
+ sys.exit(1)
260
+
261
+ ok = add_server_to_profile(profile_name, server_name, all_servers[server_name])
262
+ if not ok:
263
+ console.print(f"[red]Profile '{profile_name}' not found.[/red]")
264
+ sys.exit(1)
265
+
266
+ console.print(f"[green]Added[/green] '[bold]{server_name}[/bold]' to profile '[bold]{profile_name}[/bold]'")
267
+
268
+
269
+ # ─── REMOVE ───────────────────────────────────────────────────────────────────
270
+
271
+ @cli.command()
272
+ @click.argument("profile_name")
273
+ @click.argument("server_name")
274
+ def remove(profile_name, server_name):
275
+ """Remove a server from a profile."""
276
+ ok = remove_server_from_profile(profile_name, server_name)
277
+ if not ok:
278
+ console.print("[red]Profile or server not found.[/red]")
279
+ sys.exit(1)
280
+ console.print(f"[green]Removed[/green] '[bold]{server_name}[/bold]' from '[bold]{profile_name}[/bold]'")
281
+
282
+
283
+ # ─── DELETE ───────────────────────────────────────────────────────────────────
284
+
285
+ @cli.command()
286
+ @click.argument("profile_name")
287
+ @click.confirmation_option(prompt="Are you sure you want to delete this profile?")
288
+ def delete(profile_name):
289
+ """Delete a profile."""
290
+ ok = delete_profile(profile_name)
291
+ if not ok:
292
+ console.print(f"[red]Profile '{profile_name}' not found.[/red]")
293
+ sys.exit(1)
294
+ console.print(f"[green]Deleted[/green] profile '[bold]{profile_name}[/bold]'")
295
+
296
+
297
+ # ─── ANALYZE ──────────────────────────────────────────────────────────────────
298
+
299
+ @cli.command()
300
+ @click.option("--target", default="code", type=click.Choice(["code", "desktop"]))
301
+ @click.option("--live", is_flag=True, help="Query each server live for exact token counts")
302
+ def analyze(target, live):
303
+ """Analyze current MCP config and show token savings potential."""
304
+ path = _get_config_path(target)
305
+ servers = get_all_mcp_servers(path)
306
+
307
+ if not servers:
308
+ console.print(f"[yellow]No MCP servers configured in {path}[/yellow]")
309
+ return
310
+
311
+ if live:
312
+ console.print("[dim]Querying servers live... this may take a few seconds per server.[/dim]")
313
+
314
+ est = estimate_total_tokens(servers, live=live)
315
+ total = est["total"]
316
+ pct = est["context_pct"]
317
+ color = "green" if pct < 5 else "yellow" if pct < 15 else "red"
318
+
319
+ console.print(
320
+ f"\n[bold]Current MCP overhead:[/bold] "
321
+ f"[{color}]{format_token_count(total)} ({pct}% of 200k context window)[/{color}]\n"
322
+ )
323
+
324
+ sorted_servers = sorted(est["per_server"].items(), key=lambda x: -x[1]["tokens"])
325
+
326
+ table = Table(title="Token Cost by Server", box=box.ROUNDED)
327
+ table.add_column("Server", style="cyan")
328
+ table.add_column("Est. Tokens", justify="right")
329
+ table.add_column("% of Total", justify="right")
330
+ table.add_column("Savings if removed", justify="right", style="green")
331
+ table.add_column("Source", justify="center")
332
+
333
+ for name, info in sorted_servers:
334
+ tok = info["tokens"]
335
+ src = info["source"]
336
+ pct_s = round((tok / total) * 100) if total > 0 else 0
337
+ saved = total - tok
338
+ table.add_row(
339
+ name,
340
+ format_token_count(tok),
341
+ f"{pct_s}%",
342
+ format_token_count(saved),
343
+ SOURCE_LABELS.get(src, src),
344
+ )
345
+
346
+ console.print(table)
347
+
348
+ if sorted_servers:
349
+ top_name, top_info = sorted_servers[0]
350
+ top_tok = top_info["tokens"]
351
+ console.print(
352
+ f"\n[yellow]Tip:[/yellow] '[bold]{top_name}[/bold]' costs "
353
+ f"{format_token_count(top_tok)} tokens. "
354
+ f"Create a lean profile without it:\n"
355
+ f" [bold]mcpswitch import --name lean[/bold]\n"
356
+ f" [bold]mcpswitch remove lean {top_name}[/bold]"
357
+ )
358
+
359
+
360
+ # ─── SAVE ─────────────────────────────────────────────────────────────────────
361
+
362
+ @cli.command(name="save")
363
+ @click.argument("profile_name")
364
+ @click.option("--target", default="code", type=click.Choice(["code", "desktop"]))
365
+ def save_current(profile_name, target):
366
+ """Save current active MCP config as a named profile."""
367
+ path = _get_config_path(target)
368
+ servers = get_all_mcp_servers(path)
369
+ create_profile(profile_name, servers)
370
+ est = estimate_total_tokens(servers)
371
+ console.print(
372
+ f"[green]Saved[/green] '[bold]{profile_name}[/bold]' "
373
+ f"({len(servers)} servers, {format_token_count(est['total'])} tokens)"
374
+ )
375
+
376
+
377
+ # ─── CACHE ────────────────────────────────────────────────────────────────────
378
+
379
+ @cli.group()
380
+ def cache():
381
+ """Manage the token count cache."""
382
+
383
+
384
+ @cache.command(name="list")
385
+ def cache_list():
386
+ """Show all cached server token counts."""
387
+ entries = list_cache()
388
+ if not entries:
389
+ console.print("[yellow]Cache is empty. Run [bold]mcpswitch analyze --live[/bold] to populate it.[/yellow]")
390
+ return
391
+
392
+ table = Table(title="Token Cache", box=box.ROUNDED)
393
+ table.add_column("Server", style="cyan")
394
+ table.add_column("Tokens", justify="right")
395
+ table.add_column("Tools", justify="right")
396
+ table.add_column("Age (hours)", justify="right")
397
+
398
+ for e in entries:
399
+ table.add_row(e["server"], format_token_count(e["tokens"]), str(e["tools"]), str(e["age_hours"]))
400
+
401
+ console.print(table)
402
+
403
+
404
+ @cache.command(name="clear")
405
+ @click.argument("server_name", required=False)
406
+ def cache_clear(server_name):
407
+ """Clear cache for one server, or all servers if no name given."""
408
+ n = clear_cache(server_name)
409
+ if server_name:
410
+ console.print(f"[green]Cleared[/green] cache for '{server_name}' ({n} entries removed)")
411
+ else:
412
+ console.print(f"[green]Cleared[/green] full cache ({n} entries removed)")
413
+
414
+
415
+ # ─── SCAN ─────────────────────────────────────────────────────────────────────
416
+
417
+ @cli.command()
418
+ @click.option("--target", default="code", type=click.Choice(["code", "desktop"]))
419
+ def scan(target):
420
+ """Live-query all configured servers and cache their exact token counts."""
421
+ path = _get_config_path(target)
422
+ servers = get_all_mcp_servers(path)
423
+
424
+ if not servers:
425
+ console.print(f"[yellow]No MCP servers configured.[/yellow]")
426
+ return
427
+
428
+ console.print(f"[bold]Scanning {len(servers)} server(s)...[/bold]\n")
429
+
430
+ results = []
431
+ for name, cfg in servers.items():
432
+ console.print(f" Querying [cyan]{name}[/cyan]...", end=" ")
433
+ from .tokens import get_server_tokens
434
+ tokens, source = get_server_tokens(name, cfg, live=True)
435
+ label = "[green]live[/green]" if source == "live" else "[yellow]fallback[/yellow]"
436
+ console.print(f"{format_token_count(tokens)} ({label})")
437
+ results.append((name, tokens, source))
438
+
439
+ total = sum(r[1] for r in results)
440
+ pct = round((total / 200_000) * 100, 1)
441
+ color = "green" if pct < 5 else "yellow" if pct < 15 else "red"
442
+
443
+ live_count = sum(1 for r in results if r[2] == "live")
444
+ fallback_count = sum(1 for r in results if r[2] == "fallback")
445
+
446
+ console.print(
447
+ f"\n[bold]Total:[/bold] [{color}]{format_token_count(total)} ({pct}% of context)[/{color}]"
448
+ )
449
+ if live_count:
450
+ console.print(f"[dim]{live_count} server(s) queried live and cached. Fallbacks: {fallback_count}.[/dim]")
451
+ else:
452
+ console.print(f"[yellow]All {fallback_count} server(s) unreachable (not running?). Using fallback estimates.[/yellow]")
453
+
454
+
455
+ # ─── SETUP (install hooks) ────────────────────────────────────────────────────
456
+
457
+ @cli.command()
458
+ @click.option("--dry-run", is_flag=True, help="Show what would be changed without writing")
459
+ def setup(dry_run):
460
+ """Install MCPSwitch hooks into Claude Code (PostToolUse + PreToolUse)."""
461
+ result = install_hooks(dry_run=dry_run)
462
+
463
+ if dry_run:
464
+ console.print("[dim]Dry run — no changes made.[/dim]")
465
+
466
+ if result["added"]:
467
+ for hook_type in result["added"]:
468
+ console.print(f"[green]Installed[/green] {hook_type} hook")
469
+ console.print(f"\n[bold]Settings file:[/bold] {result['settings_path']}")
470
+ console.print(
471
+ "\n[bold green]Hooks active.[/bold green] "
472
+ "MCPSwitch will now:\n"
473
+ " - Log every MCP tool call (usage tracking)\n"
474
+ " - Warn if a tool's MCP server is not in your active profile\n"
475
+ "\nRestart Claude Code for hooks to take effect."
476
+ )
477
+ if result["already_existed"]:
478
+ console.print(f"[dim]Already installed: {', '.join(result['already_existed'])}[/dim]")
479
+
480
+
481
+ @cli.command()
482
+ def uninstall():
483
+ """Remove MCPSwitch hooks from Claude Code settings."""
484
+ result = uninstall_hooks()
485
+ if result["removed"]:
486
+ for hook_type in result["removed"]:
487
+ console.print(f"[green]Removed[/green] {hook_type} hook")
488
+ else:
489
+ console.print("[yellow]No MCPSwitch hooks found.[/yellow]")
490
+
491
+
492
+ # ─── AUTO (context-aware switch) ──────────────────────────────────────────────
493
+
494
+ @cli.command()
495
+ @click.option("--dir", "directory", default=None, help="Project directory to analyze")
496
+ @click.option("--dry-run", is_flag=True, help="Show recommendation without switching")
497
+ @click.option("--force", is_flag=True, help="Switch even if confidence is low")
498
+ @click.option("--silent", is_flag=True, help="No output if already on best profile")
499
+ def auto(directory, dry_run, force, silent):
500
+ """Auto-select best profile based on project context and conversation history."""
501
+ if dry_run:
502
+ result = select_best_profile(directory=directory)
503
+ scores = result["scores"]
504
+
505
+ console.print(f"\n[bold]Context detected:[/bold]")
506
+ if result["context_servers"]:
507
+ console.print(f" Project signals : {', '.join(result['context_servers'])}")
508
+ if result["conversation_servers"]:
509
+ console.print(f" Conversation : {', '.join(result['conversation_servers'])}")
510
+
511
+ if not result["context_servers"] and not result["conversation_servers"]:
512
+ console.print(" [dim]No signals detected[/dim]")
513
+
514
+ console.print(f"\n[bold]Profile scores:[/bold]")
515
+ for name, score in sorted(scores.items(), key=lambda x: -x[1]):
516
+ bar = "[green]" if name == result["recommended"] else "[dim]"
517
+ console.print(f" {bar}{name:<20} {score:.2f}[/{bar.strip('[]')}]")
518
+
519
+ console.print(
520
+ f"\n[bold]Recommendation:[/bold] [cyan]{result['recommended']}[/cyan] "
521
+ f"(confidence: {result['confidence']:.0%})\n"
522
+ f"[dim]{result['reason']}[/dim]"
523
+ )
524
+ return
525
+
526
+ result = auto_switch(directory=directory, force=force)
527
+
528
+ if result["needs_confirmation"]:
529
+ console.print(
530
+ f"\n[yellow]Low confidence ({result['confidence']:.0%})[/yellow] — "
531
+ f"recommended: [bold]{result['to_profile']}[/bold]\n"
532
+ f"[dim]{result['reason']}[/dim]\n"
533
+ )
534
+ if click.confirm(f"Switch to '{result['to_profile']}'?"):
535
+ result = auto_switch(directory=directory, force=True)
536
+ else:
537
+ console.print("[dim]No change.[/dim]")
538
+ return
539
+
540
+ if result["switched"]:
541
+ console.print(
542
+ f"[green]Auto-switched[/green] "
543
+ f"[dim]{result['from_profile'] or 'none'}[/dim] -> "
544
+ f"[bold]{result['to_profile']}[/bold] "
545
+ f"(confidence: {result['confidence']:.0%})\n"
546
+ f"[dim]{result['reason']}[/dim]"
547
+ )
548
+ else:
549
+ if not silent:
550
+ console.print(
551
+ f"[dim]Already on best profile: {result['to_profile']} "
552
+ f"({result['confidence']:.0%} match)[/dim]"
553
+ )
554
+
555
+
556
+ # ─── USAGE (Pro) ──────────────────────────────────────────────────────────────
557
+
558
+ @cli.command()
559
+ @click.option("--days", default=30, help="Number of days to report on")
560
+ @click.option("--server", default=None, help="Show top tools for a specific server")
561
+ def usage(days, server):
562
+ """Show MCP tool usage stats. [Pro feature]"""
563
+ from .tier import maybe_show_donation_nudge
564
+ maybe_show_donation_nudge(console)
565
+
566
+ if server:
567
+ tools = get_top_tools_by_server(server, limit=20)
568
+ if not tools:
569
+ console.print(f"[yellow]No usage data for '{server}' yet.[/yellow]")
570
+ return
571
+ table = Table(title=f"Top Tools — {server}", box=box.ROUNDED)
572
+ table.add_column("Tool", style="cyan")
573
+ table.add_column("Calls", justify="right")
574
+ for t in tools:
575
+ table.add_row(t["tool"].replace(f"mcp__{server}__", ""), str(t["calls"]))
576
+ console.print(table)
577
+ return
578
+
579
+ stats = get_server_usage_stats(days=days)
580
+ summary = get_usage_summary(days=days)
581
+
582
+ if not stats:
583
+ console.print(
584
+ f"[yellow]No usage data for the last {days} days.[/yellow]\n"
585
+ "Make sure [bold]mcpswitch setup[/bold] was run to install hooks."
586
+ )
587
+ return
588
+
589
+ console.print(Panel(
590
+ f"Last {days} days | "
591
+ f"Total calls: [bold]{summary['total_calls']}[/bold] | "
592
+ f"Servers used: [bold]{summary['unique_servers']}[/bold]",
593
+ title="[bold cyan]MCP Usage Summary[/bold cyan]"
594
+ ))
595
+
596
+ table = Table(box=box.ROUNDED)
597
+ table.add_column("Server", style="cyan")
598
+ table.add_column("Total Calls", justify="right")
599
+ table.add_column("Sessions Used", justify="right")
600
+ table.add_column("Active Days", justify="right")
601
+ table.add_column("Last Used", justify="right")
602
+
603
+ for s in stats:
604
+ last = f"{s['last_used_days_ago']}d ago" if s["last_used_days_ago"] else "never"
605
+ table.add_row(
606
+ s["server"],
607
+ str(s["total_calls"]),
608
+ str(s["sessions_used"]),
609
+ str(s["active_days"]),
610
+ last,
611
+ )
612
+ console.print(table)
613
+
614
+
615
+ # ─── WASTE (Pro) ──────────────────────────────────────────────────────────────
616
+
617
+ @cli.command()
618
+ @click.option("--days", default=30, help="Look back N days")
619
+ @click.option("--target", default="code", type=click.Choice(["code", "desktop"]))
620
+ @click.option("--fix", is_flag=True, help="Auto-remove wasteful servers from active profile")
621
+ def waste(days, target, fix):
622
+ """Find MCP servers loaded but never/rarely used. [Pro feature]"""
623
+ from .tier import maybe_show_donation_nudge
624
+ maybe_show_donation_nudge(console)
625
+
626
+ path = _get_config_path(target)
627
+ servers = get_all_mcp_servers(path)
628
+
629
+ if not servers:
630
+ console.print("[yellow]No MCP servers configured.[/yellow]")
631
+ return
632
+
633
+ waste_list = get_waste_report(list(servers.keys()), days=days)
634
+
635
+ if not waste_list:
636
+ console.print(
637
+ f"[green]No waste detected.[/green] All loaded servers were called "
638
+ f"in the last {days} days."
639
+ )
640
+ return
641
+
642
+ est_current = estimate_total_tokens(servers)
643
+ waste_tokens = sum(
644
+ estimate_total_tokens({w["server"]: servers[w["server"]]})["total"]
645
+ for w in waste_list
646
+ if w["server"] in servers
647
+ )
648
+
649
+ console.print(f"\n[bold yellow]Waste detected:[/bold yellow] {len(waste_list)} server(s)\n")
650
+
651
+ table = Table(box=box.ROUNDED)
652
+ table.add_column("Server", style="cyan")
653
+ table.add_column("Calls", justify="right")
654
+ table.add_column("Waste Level", justify="center")
655
+ table.add_column("Est. Tokens Wasted", justify="right", style="red")
656
+ table.add_column("Recommendation")
657
+
658
+ for w in waste_list:
659
+ level_color = "red" if w["waste_level"] == "high" else "yellow"
660
+ tok = estimate_total_tokens({w["server"]: servers.get(w["server"], {})})["total"]
661
+ table.add_row(
662
+ w["server"],
663
+ str(w["total_calls"]),
664
+ f"[{level_color}]{w['waste_level']}[/{level_color}]",
665
+ format_token_count(tok),
666
+ w["recommendation"],
667
+ )
668
+
669
+ console.print(table)
670
+ console.print(
671
+ f"\n[bold]Total tokens wasted per session:[/bold] "
672
+ f"[red]{format_token_count(waste_tokens)}[/red] "
673
+ f"({round(waste_tokens/est_current['total']*100)}% of your current overhead)"
674
+ )
675
+
676
+ if fix:
677
+ active = get_active_profile()
678
+ if not active:
679
+ console.print("[yellow]No active profile. Use --fix with an active profile.[/yellow]")
680
+ return
681
+ removed = 0
682
+ for w in waste_list:
683
+ from .profiles import remove_server_from_profile
684
+ ok = remove_server_from_profile(active, w["server"])
685
+ if ok:
686
+ console.print(f"[green]Removed[/green] '{w['server']}' from profile '{active}'")
687
+ removed += 1
688
+ if removed:
689
+ console.print(f"\n[bold green]Fixed.[/bold green] Run [bold]mcpswitch use {active}[/bold] to apply.")
690
+ else:
691
+ console.print(
692
+ f"\nTo fix: [bold]mcpswitch waste --fix[/bold] (removes waste from active profile)"
693
+ )
694
+
695
+
696
+ # ─── ACTIVATE (license) ───────────────────────────────────────────────────────
697
+
698
+ @cli.command()
699
+ @click.argument("license_key")
700
+ def activate(license_key):
701
+ """Activate a Pro or Team license key."""
702
+ from .tier import activate_license, get_tier
703
+ result = activate_license(license_key)
704
+ if result["success"]:
705
+ console.print(f"[bold green]{result['message']}[/bold green]")
706
+ console.print(f"Tier: [bold]{get_tier().upper()}[/bold]")
707
+ else:
708
+ console.print(f"[red]{result['message']}[/red]")
709
+ sys.exit(1)
710
+
711
+
712
+ @cli.command()
713
+ def tier():
714
+ """Show current license tier."""
715
+ from .tier import get_tier, _load_license
716
+ from .billing import TEAM_PAYMENT_URL
717
+ t = get_tier()
718
+ lic = _load_license()
719
+ colors = {"free": "dim", "pro": "green", "team": "bold cyan"}
720
+ color = colors.get(t, "dim")
721
+ console.print(f"Current tier: [{color}]{t.upper()}[/{color}]")
722
+ if t == "team":
723
+ seats = lic.get("seats", 1)
724
+ console.print(f"Seats: [bold]{seats}[/bold]")
725
+ if t == "free":
726
+ console.print(
727
+ f"Upgrade to Team for shared profiles & Slack alerts:\n"
728
+ f" [bold]mcpswitch upgrade[/bold]\n"
729
+ f" [cyan]{TEAM_PAYMENT_URL}[/cyan]"
730
+ )
731
+
732
+
733
+ # ─── DONATE ──────────────────────────────────────────────────────────────────
734
+
735
+ @cli.command()
736
+ def donate():
737
+ """Support MCPSwitch with a one-time tip (always optional)."""
738
+ import webbrowser
739
+ donate_url = "https://ko-fi.com/mcpswitch"
740
+ webbrowser.open(donate_url)
741
+ console.print(
742
+ Panel(
743
+ "[bold]Thanks for considering a tip![/bold]\n\n"
744
+ "MCPSwitch is free and open-source. Every contribution helps\n"
745
+ "keep development active.\n\n"
746
+ f"[cyan]{donate_url}[/cyan]\n\n"
747
+ "[dim]You can always close the browser — no pressure.[/dim]",
748
+ title="Support MCPSwitch",
749
+ border_style="yellow",
750
+ )
751
+ )
752
+ from .tier import record_nudge_shown
753
+ record_nudge_shown() # reset the nudge timer after they've seen the page
754
+
755
+
756
+ # ─── UPGRADE ─────────────────────────────────────────────────────────────────
757
+
758
+ @cli.command()
759
+ def upgrade():
760
+ """Open the Team tier checkout page in your browser."""
761
+ from .billing import open_checkout, TEAM_PAYMENT_URL
762
+ from .tier import is_team
763
+ if is_team():
764
+ console.print("[bold green]You already have the Team tier active.[/bold green]")
765
+ return
766
+ url = open_checkout()
767
+ console.print(
768
+ Panel(
769
+ f"[bold]Opening Lemon Squeezy checkout...[/bold]\n\n"
770
+ f"After payment you will receive a license key by email.\n"
771
+ f"Activate it with:\n\n"
772
+ f" [bold cyan]mcpswitch activate <your-key>[/bold cyan]\n\n"
773
+ f"If the browser didn't open, visit:\n[cyan]{url}[/cyan]",
774
+ title="MCPSwitch Team",
775
+ border_style="cyan",
776
+ )
777
+ )
778
+
779
+
780
+ # ─── DIGEST (Pro) ─────────────────────────────────────────────────────────────
781
+
782
+ @cli.group()
783
+ def digest():
784
+ """Weekly token savings digest via email. [Pro feature]"""
785
+
786
+
787
+ @digest.command(name="setup")
788
+ @click.argument("email")
789
+ @click.option("--api-key", required=True, prompt="Resend API key", help="Resend.com API key")
790
+ def digest_setup(email, api_key):
791
+ """Configure weekly digest email (requires Resend API key)."""
792
+ from .tier import maybe_show_donation_nudge
793
+ maybe_show_donation_nudge(console)
794
+
795
+ from .email import setup_digest
796
+ result = setup_digest(email, api_key)
797
+ console.print(f"[green]Digest configured.[/green] Reports will be sent to [bold]{result['email']}[/bold]")
798
+ console.print("Send now: [bold]mcpswitch digest --send[/bold]")
799
+
800
+
801
+ @digest.command(name="send")
802
+ @click.option("--force", is_flag=True, help="Send even if already sent this week")
803
+ def digest_send(force):
804
+ """Send the weekly digest email now."""
805
+ from .tier import maybe_show_donation_nudge
806
+ maybe_show_donation_nudge(console)
807
+
808
+ from .email import send_digest
809
+ result = send_digest(force=force)
810
+
811
+ if result.get("skipped"):
812
+ console.print(f"[yellow]{result['message']}[/yellow]")
813
+ return
814
+
815
+ if result["success"]:
816
+ console.print(f"[green]{result['message']}[/green]")
817
+ else:
818
+ console.print(f"[red]{result['message']}[/red]")
819
+ sys.exit(1)
820
+
821
+
822
+ @digest.command(name="preview")
823
+ def digest_preview():
824
+ """Preview this week's digest without sending."""
825
+ from .tier import maybe_show_donation_nudge
826
+ maybe_show_donation_nudge(console)
827
+
828
+ from .email import preview_digest
829
+ console.print(preview_digest())
830
+
831
+
832
+ @digest.command(name="status")
833
+ def digest_status():
834
+ """Show digest configuration and last send time."""
835
+ from .tier import maybe_show_donation_nudge
836
+ maybe_show_donation_nudge(console)
837
+
838
+ from .email import get_digest_config
839
+ import time
840
+ config = get_digest_config()
841
+ if not config:
842
+ console.print("[yellow]Digest not configured. Run: mcpswitch digest setup <email> --api-key <key>[/yellow]")
843
+ return
844
+
845
+ last = config.get("last_sent")
846
+ last_str = f"{round((time.time() - last) / 86400, 1)}d ago" if last else "never"
847
+ console.print(f"Email : [bold]{config.get('email', 'not set')}[/bold]")
848
+ console.print(f"API key : [dim]{'configured' if config.get('resend_api_key') else 'missing'}[/dim]")
849
+ console.print(f"Last sent: {last_str}")
850
+
851
+
852
+ # ─── COMMUNITY (Pro) ──────────────────────────────────────────────────────────
853
+
854
+ @cli.group()
855
+ def community():
856
+ """Browse and install community MCP profile stacks. [Pro feature]"""
857
+
858
+
859
+ @community.command(name="list")
860
+ @click.option("--tag", default=None, help="Filter by tag (e.g. python, react, devops)")
861
+ def community_list(tag):
862
+ """Browse available community profiles."""
863
+ from .tier import maybe_show_donation_nudge
864
+ maybe_show_donation_nudge(console)
865
+
866
+ from .community import list_community_profiles
867
+ profiles = list_community_profiles(tag=tag)
868
+
869
+ if not profiles:
870
+ console.print(f"[yellow]No profiles found{' for tag: ' + tag if tag else ''}.[/yellow]")
871
+ return
872
+
873
+ table = Table(
874
+ title=f"Community Profiles{' [tag: ' + tag + ']' if tag else ''}",
875
+ box=box.ROUNDED
876
+ )
877
+ table.add_column("Name", style="bold cyan")
878
+ table.add_column("Description")
879
+ table.add_column("Tags", style="dim")
880
+ table.add_column("Servers", justify="center")
881
+ table.add_column("Stars", justify="right")
882
+ table.add_column("Author", style="dim")
883
+
884
+ for p in profiles:
885
+ table.add_row(
886
+ p["name"],
887
+ p.get("description", ""),
888
+ ", ".join(p.get("tags", [])),
889
+ str(len(p.get("servers", []))),
890
+ str(p.get("stars", 0)),
891
+ p.get("author", ""),
892
+ )
893
+
894
+ console.print(table)
895
+ console.print("\nInstall: [bold]mcpswitch community install <name>[/bold]")
896
+
897
+
898
+ @community.command(name="info")
899
+ @click.argument("profile_name")
900
+ def community_info(profile_name):
901
+ """Show details about a community profile."""
902
+ from .tier import maybe_show_donation_nudge
903
+ maybe_show_donation_nudge(console)
904
+
905
+ from .community import get_profile_detail
906
+ detail = get_profile_detail(profile_name)
907
+ if not detail:
908
+ console.print(f"[red]Profile '{profile_name}' not found.[/red]")
909
+ sys.exit(1)
910
+
911
+ console.print(Panel(
912
+ f"[bold]{detail['name']}[/bold]\n"
913
+ f"{detail.get('description', '')}\n\n"
914
+ f"Author : {detail.get('author', 'unknown')}\n"
915
+ f"Tags : {', '.join(detail.get('tags', []))}\n"
916
+ f"Servers: {', '.join(detail.get('servers', []))}",
917
+ title="Community Profile"
918
+ ))
919
+
920
+
921
+ @community.command(name="install")
922
+ @click.argument("profile_name")
923
+ def community_install(profile_name):
924
+ """Install a community profile locally."""
925
+ from .tier import maybe_show_donation_nudge
926
+ maybe_show_donation_nudge(console)
927
+
928
+ from .community import install_community_profile
929
+ result = install_community_profile(profile_name)
930
+ if result["success"]:
931
+ console.print(f"[green]{result['message']}[/green]")
932
+ console.print(f"Servers: {', '.join(result['servers'])}")
933
+ else:
934
+ console.print(f"[red]{result['message']}[/red]")
935
+ sys.exit(1)
936
+
937
+
938
+ @community.command(name="search")
939
+ @click.argument("query")
940
+ def community_search(query):
941
+ """Search community profiles by name, description, or tag."""
942
+ from .tier import maybe_show_donation_nudge
943
+ maybe_show_donation_nudge(console)
944
+
945
+ from .community import search_profiles
946
+ results = search_profiles(query)
947
+ if not results:
948
+ console.print(f"[yellow]No profiles matching '{query}'.[/yellow]")
949
+ return
950
+
951
+ for p in results:
952
+ console.print(
953
+ f"[bold cyan]{p['name']}[/bold cyan] — {p.get('description', '')} "
954
+ f"[dim]({', '.join(p.get('tags', []))})[/dim]"
955
+ )
956
+
957
+
958
+ @community.command(name="publish")
959
+ @click.argument("profile_name")
960
+ @click.option("--author", default="", help="Your name or GitHub handle")
961
+ def community_publish(profile_name, author):
962
+ """Get the URL to submit your profile to the community registry."""
963
+ from .tier import maybe_show_donation_nudge
964
+ maybe_show_donation_nudge(console)
965
+
966
+ from .community import publish_profile_url
967
+ url = publish_profile_url(profile_name, author)
968
+ console.print(f"Open this URL to submit your profile:\n[cyan]{url}[/cyan]")
969
+
970
+
971
+ # ─── SYNC (Pro) ───────────────────────────────────────────────────────────────
972
+
973
+ @cli.group()
974
+ def sync():
975
+ """Multi-machine profile sync via GitHub Gists. [Pro feature]"""
976
+
977
+
978
+ @sync.command(name="setup")
979
+ @click.argument("github_token")
980
+ def sync_setup(github_token):
981
+ """Create a private Gist and configure sync."""
982
+ from .tier import maybe_show_donation_nudge
983
+ maybe_show_donation_nudge(console)
984
+
985
+ from .sync import setup_sync
986
+ result = setup_sync(github_token)
987
+ if result["success"]:
988
+ console.print(f"[green]{result['message']}[/green]")
989
+ console.print(f"Gist ID : [dim]{result['gist_id']}[/dim]")
990
+ else:
991
+ console.print(f"[red]{result['message']}[/red]")
992
+ sys.exit(1)
993
+
994
+
995
+ @sync.command(name="push")
996
+ def sync_push():
997
+ """Push local profiles to GitHub Gist."""
998
+ from .tier import maybe_show_donation_nudge
999
+ maybe_show_donation_nudge(console)
1000
+
1001
+ from .sync import push_profiles
1002
+ result = push_profiles()
1003
+ if result["success"]:
1004
+ console.print(f"[green]{result['message']}[/green]")
1005
+ else:
1006
+ console.print(f"[red]{result['message']}[/red]")
1007
+ sys.exit(1)
1008
+
1009
+
1010
+ @sync.command(name="pull")
1011
+ @click.option("--no-merge", is_flag=True, help="Replace local profiles instead of merging")
1012
+ def sync_pull(no_merge):
1013
+ """Pull profiles from GitHub Gist."""
1014
+ from .tier import maybe_show_donation_nudge
1015
+ maybe_show_donation_nudge(console)
1016
+
1017
+ from .sync import pull_profiles
1018
+ result = pull_profiles(merge=not no_merge)
1019
+ if result["success"]:
1020
+ console.print(f"[green]{result['message']}[/green]")
1021
+ console.print(
1022
+ f"New: {result['new_profiles']} | "
1023
+ f"Updated: {result['updated_profiles']} | "
1024
+ f"Total: {result['total_profiles']}"
1025
+ )
1026
+ else:
1027
+ console.print(f"[red]{result['message']}[/red]")
1028
+ sys.exit(1)
1029
+
1030
+
1031
+ @sync.command(name="status")
1032
+ def sync_status():
1033
+ """Show sync configuration and last push/pull times."""
1034
+ from .tier import maybe_show_donation_nudge
1035
+ maybe_show_donation_nudge(console)
1036
+
1037
+ from .sync import get_sync_status
1038
+ s = get_sync_status()
1039
+ if not s.get("configured"):
1040
+ console.print("[yellow]Sync not configured. Run: mcpswitch sync setup <github-token>[/yellow]")
1041
+ return
1042
+
1043
+ console.print(f"Gist : [cyan]{s.get('gist_url', s.get('gist_id'))}[/cyan]")
1044
+ console.print(f"Last push: {s.get('last_push_ago', 'never')}")
1045
+ console.print(f"Last pull: {s.get('last_pull_ago', 'never')}")
1046
+
1047
+
1048
+ # ─── TEAM (Team tier) ─────────────────────────────────────────────────────────
1049
+
1050
+ @cli.group()
1051
+ def team():
1052
+ """Team shared profiles, analytics, and Slack alerts. [Team feature]"""
1053
+
1054
+
1055
+ def _require_team():
1056
+ from .tier import is_team
1057
+ from .billing import TEAM_PAYMENT_URL
1058
+ if not is_team():
1059
+ console.print(
1060
+ "\n[bold yellow]Team features require the Team tier.[/bold yellow]\n\n"
1061
+ "Upgrade to Team to unlock:\n"
1062
+ " - Shared team profiles via GitHub Gist\n"
1063
+ " - Slack alerts for context overload and waste\n"
1064
+ " - Team-wide analytics and monthly reports\n\n"
1065
+ "Run [bold cyan]mcpswitch upgrade[/bold cyan] to get started.\n"
1066
+ f"Or visit: [cyan]{TEAM_PAYMENT_URL}[/cyan]\n"
1067
+ "Then activate: [bold]mcpswitch activate <your-key>[/bold]\n"
1068
+ )
1069
+ return False
1070
+ return True
1071
+
1072
+
1073
+ @team.command(name="create-gist")
1074
+ @click.argument("github_token")
1075
+ def team_create_gist(github_token):
1076
+ """Create a new shared team Gist (admin action)."""
1077
+ if not _require_team():
1078
+ return
1079
+
1080
+ from .team import create_team_gist
1081
+ result = create_team_gist(github_token)
1082
+ if result["success"]:
1083
+ console.print(f"[green]{result['message']}[/green]")
1084
+ else:
1085
+ console.print(f"[red]{result['message']}[/red]")
1086
+ sys.exit(1)
1087
+
1088
+
1089
+ @team.command(name="setup")
1090
+ @click.argument("gist_id")
1091
+ @click.argument("github_token")
1092
+ def team_setup(gist_id, github_token):
1093
+ """Connect this machine to an existing team Gist."""
1094
+ if not _require_team():
1095
+ return
1096
+
1097
+ from .team import setup_team
1098
+ result = setup_team(gist_id, github_token)
1099
+ if result["success"]:
1100
+ console.print(f"[green]{result['message']}[/green]")
1101
+ else:
1102
+ console.print(f"[red]{result['message']}[/red]")
1103
+ sys.exit(1)
1104
+
1105
+
1106
+ @team.command(name="sync")
1107
+ @click.option("--push/--no-push", default=True, help="Push local profiles to team Gist")
1108
+ @click.option("--pull/--no-pull", default=True, help="Pull team profiles locally")
1109
+ def team_sync(push, pull):
1110
+ """Sync profiles with the team Gist (bidirectional by default)."""
1111
+ if not _require_team():
1112
+ return
1113
+
1114
+ from .team import sync_team_profiles
1115
+ result = sync_team_profiles(push=push, pull=pull)
1116
+ if result["success"]:
1117
+ console.print(f"[green]{result['message']}[/green]")
1118
+ console.print(
1119
+ f"Pushed: {result['pushed']} | "
1120
+ f"Pulled: {result['pulled']} | "
1121
+ f"Team members: {result['team_members']} | "
1122
+ f"Total profiles: {result['total_profiles']}"
1123
+ )
1124
+ else:
1125
+ console.print(f"[red]{result['message']}[/red]")
1126
+ sys.exit(1)
1127
+
1128
+
1129
+ @team.command(name="status")
1130
+ def team_status():
1131
+ """Show team sync status and member activity."""
1132
+ if not _require_team():
1133
+ return
1134
+
1135
+ from .team import get_team_status
1136
+ import time
1137
+ s = get_team_status()
1138
+ if not s.get("configured"):
1139
+ console.print("[yellow]Team not configured. Run: mcpswitch team setup <gist-id> <token>[/yellow]")
1140
+ return
1141
+
1142
+ console.print(f"Gist : [cyan]{s.get('gist_url', s.get('gist_id'))}[/cyan]")
1143
+ console.print(f"Role : {'[bold]Admin[/bold]' if s.get('is_admin') else 'Member'}")
1144
+ last = s.get("last_sync")
1145
+ last_str = f"{round((time.time() - last) / 60)}m ago" if last else "never"
1146
+ console.print(f"Last sync: {last_str}")
1147
+
1148
+ members = s.get("members", [])
1149
+ if members:
1150
+ table = Table(title="Team Members", box=box.SIMPLE)
1151
+ table.add_column("Machine", style="cyan")
1152
+ table.add_column("Profiles", justify="right")
1153
+ table.add_column("Last Seen")
1154
+ table.add_column("Status", justify="center")
1155
+ for m in members:
1156
+ last_seen = m.get("last_seen")
1157
+ ago = f"{round((time.time() - last_seen) / 3600)}h ago" if last_seen else "never"
1158
+ status = "[green]active[/green]" if m.get("active") else "[dim]idle[/dim]"
1159
+ table.add_row(m["machine"], str(m.get("profiles", 0)), ago, status)
1160
+ console.print(table)
1161
+
1162
+
1163
+ @team.command(name="report")
1164
+ @click.option("--days", default=30)
1165
+ def team_report(days):
1166
+ """Show team-wide analytics (aggregated from all members)."""
1167
+ if not _require_team():
1168
+ return
1169
+
1170
+ from .team import get_full_team_analytics, get_team_report
1171
+ data = get_full_team_analytics()
1172
+ if not data.get("success"):
1173
+ console.print(f"[yellow]Using local stats only: {data.get('message', '')}[/yellow]")
1174
+ data = get_team_report(days=days)
1175
+ data["member_count"] = 1
1176
+ data["top_servers_across_team"] = data.get("top_servers", [])
1177
+
1178
+ console.print(Panel(
1179
+ f"Members: [bold]{data.get('member_count', 1)}[/bold] | "
1180
+ f"Total calls: [bold]{data.get('total_calls', 0):,}[/bold] | "
1181
+ f"Waste alerts: [bold]{data.get('waste_count', 0)}[/bold]",
1182
+ title="[bold cyan]Team Analytics (30d)[/bold cyan]"
1183
+ ))
1184
+
1185
+ top = data.get("top_servers_across_team", [])
1186
+ if top:
1187
+ table = Table(title="Top Servers (Team-wide)", box=box.ROUNDED)
1188
+ table.add_column("Server", style="cyan")
1189
+ table.add_column("Total Calls", justify="right")
1190
+ for s in top[:10]:
1191
+ table.add_row(s["server"], str(s["calls"]))
1192
+ console.print(table)
1193
+
1194
+ waste = data.get("waste_servers", [])
1195
+ if waste:
1196
+ console.print(f"\n[yellow]{len(waste)} waste alert(s):[/yellow]")
1197
+ for w in waste[:5]:
1198
+ console.print(f" [red]{w['server']}[/red] — {w.get('waste_level', '?')} ({w.get('total_calls', 0)} calls)")
1199
+ console.print("Fix: [bold]mcpswitch waste --fix[/bold]")
1200
+
1201
+
1202
+ @team.command(name="push-analytics")
1203
+ def team_push_analytics():
1204
+ """Upload this machine's usage stats to the team Gist."""
1205
+ if not _require_team():
1206
+ return
1207
+
1208
+ from .team import push_analytics_to_gist
1209
+ result = push_analytics_to_gist()
1210
+ if result["success"]:
1211
+ console.print(f"[green]{result['message']}[/green]")
1212
+ else:
1213
+ console.print(f"[red]{result['message']}[/red]")
1214
+ sys.exit(1)
1215
+
1216
+
1217
+ @team.group()
1218
+ def slack():
1219
+ """Slack alert configuration."""
1220
+
1221
+
1222
+ @slack.command(name="setup")
1223
+ @click.argument("webhook_url")
1224
+ @click.option("--threshold", default=80.0, help="Context % that triggers an alert (default: 80)")
1225
+ def slack_setup(webhook_url, threshold):
1226
+ """Configure Slack webhook for waste and context alerts."""
1227
+ if not _require_team():
1228
+ return
1229
+
1230
+ from .team import setup_slack
1231
+ result = setup_slack(webhook_url, threshold_pct=threshold)
1232
+ console.print(f"[green]{result['message']}[/green]")
1233
+ console.print("Test it: [bold]mcpswitch team slack test[/bold]")
1234
+
1235
+
1236
+ @slack.command(name="test")
1237
+ def slack_test():
1238
+ """Send a test message to verify the Slack webhook."""
1239
+ if not _require_team():
1240
+ return
1241
+
1242
+ from .team import send_slack_test
1243
+ result = send_slack_test()
1244
+ if result["success"]:
1245
+ console.print(f"[green]{result['message']}[/green]")
1246
+ else:
1247
+ console.print(f"[red]{result['message']}[/red]")
1248
+ sys.exit(1)
1249
+
1250
+
1251
+ @slack.command(name="alert")
1252
+ @click.option("--force", is_flag=True, help="Send even if below threshold")
1253
+ def slack_alert(force):
1254
+ """Check context usage and send Slack alert if over threshold."""
1255
+ if not _require_team():
1256
+ return
1257
+
1258
+ from .team import check_and_alert
1259
+ result = check_and_alert(force=force)
1260
+ if result.get("alerted"):
1261
+ console.print(f"[green]{result['message']}[/green] (context: {result.get('context_pct', 0):.1f}%)")
1262
+ elif result.get("success"):
1263
+ console.print(f"[dim]{result['message']}[/dim]")
1264
+ else:
1265
+ console.print(f"[red]{result['message']}[/red]")
1266
+ sys.exit(1)
1267
+
1268
+
1269
+ @slack.command(name="send-report")
1270
+ def slack_send_report():
1271
+ """Post the monthly team analytics report to Slack."""
1272
+ if not _require_team():
1273
+ return
1274
+
1275
+ from .team import send_monthly_report_to_slack
1276
+ result = send_monthly_report_to_slack()
1277
+ if result["success"]:
1278
+ console.print(f"[green]{result['message']}[/green]")
1279
+ else:
1280
+ console.print(f"[red]{result['message']}[/red]")
1281
+ sys.exit(1)
1282
+
1283
+
1284
+ def main():
1285
+ cli()
1286
+
1287
+
1288
+ if __name__ == "__main__":
1289
+ main()