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/__init__.py +3 -0
- mcpswitch/auto.py +353 -0
- mcpswitch/billing.py +173 -0
- mcpswitch/cli.py +1289 -0
- mcpswitch/community.py +209 -0
- mcpswitch/config.py +62 -0
- mcpswitch/email.py +204 -0
- mcpswitch/hooks.py +269 -0
- mcpswitch/profiles.py +96 -0
- mcpswitch/sync.py +237 -0
- mcpswitch/team.py +588 -0
- mcpswitch/tier.py +170 -0
- mcpswitch/tokens.py +426 -0
- mcpswitch/usage.py +232 -0
- mcpswitch_cli-0.1.0.dist-info/METADATA +130 -0
- mcpswitch_cli-0.1.0.dist-info/RECORD +19 -0
- mcpswitch_cli-0.1.0.dist-info/WHEEL +5 -0
- mcpswitch_cli-0.1.0.dist-info/entry_points.txt +2 -0
- mcpswitch_cli-0.1.0.dist-info/top_level.txt +1 -0
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()
|