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/team.py ADDED
@@ -0,0 +1,588 @@
1
+ """Team tier features — shared profiles, Slack alerts, team analytics.
2
+
3
+ Team tier ($49/month):
4
+ mcpswitch team --setup <gist-id> <github-token> → connect to shared team Gist
5
+ mcpswitch team --sync → sync team profiles
6
+ mcpswitch team --report → show team analytics
7
+ mcpswitch team slack --setup <webhook-url> → configure Slack alerts
8
+ mcpswitch team slack --test → send test alert
9
+ mcpswitch team slack --send-report → send monthly report to Slack
10
+
11
+ Team Gist format:
12
+ The team admin creates a shared (private) Gist.
13
+ Everyone on the team adds the same Gist ID to their config.
14
+ Profiles in the Gist are available to all team members.
15
+ """
16
+
17
+ import json
18
+ import socket
19
+ import platform
20
+ import time
21
+ import urllib.request
22
+ import urllib.error
23
+ from pathlib import Path
24
+ from typing import Optional
25
+
26
+ TEAM_CONFIG_FILE = Path.home() / ".mcpswitch" / "team.json"
27
+ GIST_API = "https://api.github.com/gists"
28
+ TEAM_GIST_FILENAME = "mcpswitch-team-profiles.json"
29
+ TEAM_ANALYTICS_FILENAME = "mcpswitch-team-analytics.json"
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Config helpers
34
+ # ---------------------------------------------------------------------------
35
+
36
+ def _load_config() -> dict:
37
+ if not TEAM_CONFIG_FILE.exists():
38
+ return {}
39
+ try:
40
+ with open(TEAM_CONFIG_FILE, "r", encoding="utf-8") as f:
41
+ return json.load(f)
42
+ except Exception:
43
+ return {}
44
+
45
+
46
+ def _save_config(data: dict) -> None:
47
+ TEAM_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
48
+ with open(TEAM_CONFIG_FILE, "w", encoding="utf-8") as f:
49
+ json.dump(data, f, indent=2)
50
+
51
+
52
+ def _machine_id() -> str:
53
+ return f"{socket.gethostname()}-{platform.system()}"
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # GitHub Gist helpers
58
+ # ---------------------------------------------------------------------------
59
+
60
+ def _gist_request(
61
+ url: str,
62
+ method: str = "GET",
63
+ token: str = "",
64
+ payload: Optional[dict] = None,
65
+ ) -> Optional[dict]:
66
+ try:
67
+ data = json.dumps(payload).encode("utf-8") if payload else None
68
+ req = urllib.request.Request(
69
+ url,
70
+ data=data,
71
+ method=method,
72
+ headers={
73
+ "Authorization": f"token {token}",
74
+ "Accept": "application/vnd.github.v3+json",
75
+ "Content-Type": "application/json",
76
+ "User-Agent": "mcpswitch/0.1",
77
+ },
78
+ )
79
+ with urllib.request.urlopen(req, timeout=10) as resp:
80
+ return json.loads(resp.read())
81
+ except urllib.error.HTTPError as e:
82
+ body = e.read().decode("utf-8", errors="ignore")
83
+ return {"error": e.code, "message": body}
84
+ except Exception as e:
85
+ return {"error": str(e)}
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Team setup
90
+ # ---------------------------------------------------------------------------
91
+
92
+ def setup_team(gist_id: str, github_token: str) -> dict:
93
+ """Connect this machine to a shared team Gist.
94
+
95
+ The team admin should:
96
+ 1. Create a private Gist manually (or via mcpswitch team --create-gist)
97
+ 2. Share the Gist ID and a read token with team members
98
+ 3. Each member runs: mcpswitch team --setup <gist_id> <token>
99
+ """
100
+ # Verify we can read the Gist
101
+ url = f"{GIST_API}/{gist_id}"
102
+ result = _gist_request(url, token=github_token)
103
+
104
+ if not result or "error" in result:
105
+ return {"success": False, "message": f"Cannot access Gist {gist_id}: {result}"}
106
+
107
+ gist_url = result.get("html_url", "")
108
+
109
+ _save_config({
110
+ "gist_id": gist_id,
111
+ "gist_url": gist_url,
112
+ "github_token": github_token,
113
+ "machine": _machine_id(),
114
+ "setup_at": time.time(),
115
+ "last_sync": None,
116
+ })
117
+
118
+ return {
119
+ "success": True,
120
+ "gist_id": gist_id,
121
+ "gist_url": gist_url,
122
+ "message": f"Team sync configured. Gist: {gist_url}",
123
+ }
124
+
125
+
126
+ def create_team_gist(github_token: str) -> dict:
127
+ """Create a new shared team Gist (admin action)."""
128
+ from .profiles import load_profiles
129
+
130
+ profiles = load_profiles()
131
+ initial_data = {
132
+ "version": 1,
133
+ "profiles": profiles,
134
+ "members": {_machine_id(): {"last_seen": time.time(), "profiles_contributed": len(profiles)}},
135
+ "created_at": time.time(),
136
+ }
137
+
138
+ payload = {
139
+ "description": "MCPSwitch Team Profiles",
140
+ "public": False,
141
+ "files": {
142
+ TEAM_GIST_FILENAME: {"content": json.dumps(initial_data, indent=2)},
143
+ TEAM_ANALYTICS_FILENAME: {"content": json.dumps({
144
+ "version": 1,
145
+ "member_stats": {},
146
+ "updated_at": time.time(),
147
+ }, indent=2)},
148
+ },
149
+ }
150
+
151
+ result = _gist_request(GIST_API, method="POST", token=github_token, payload=payload)
152
+ if not result or "error" in result:
153
+ return {"success": False, "message": f"Failed to create team Gist: {result}"}
154
+
155
+ gist_id = result["id"]
156
+ gist_url = result.get("html_url", "")
157
+
158
+ _save_config({
159
+ "gist_id": gist_id,
160
+ "gist_url": gist_url,
161
+ "github_token": github_token,
162
+ "machine": _machine_id(),
163
+ "setup_at": time.time(),
164
+ "last_sync": None,
165
+ "is_admin": True,
166
+ })
167
+
168
+ return {
169
+ "success": True,
170
+ "gist_id": gist_id,
171
+ "gist_url": gist_url,
172
+ "message": (
173
+ f"Team Gist created.\n"
174
+ f"Share this with your team:\n"
175
+ f" Gist ID: {gist_id}\n"
176
+ f" Setup command: mcpswitch team --setup {gist_id} <token>"
177
+ ),
178
+ }
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # Profile sync
183
+ # ---------------------------------------------------------------------------
184
+
185
+ def sync_team_profiles(push: bool = True, pull: bool = True) -> dict:
186
+ """Bidirectional sync: push local profiles + pull remote profiles.
187
+
188
+ push=True: upload local profiles to the shared Gist
189
+ pull=True: download team profiles to local (local profiles are not overwritten
190
+ unless remote has a newer version of the same name)
191
+ """
192
+ config = _load_config()
193
+ if not config.get("gist_id"):
194
+ return {"success": False, "message": "Team not configured. Run: mcpswitch team --setup"}
195
+
196
+ from .profiles import load_profiles, save_profiles
197
+
198
+ gist_url = f"{GIST_API}/{config['gist_id']}"
199
+ token = config["github_token"]
200
+ machine = _machine_id()
201
+
202
+ # 1. Read current Gist state
203
+ remote = _gist_request(gist_url, token=token)
204
+ if not remote or "error" in remote:
205
+ return {"success": False, "message": f"Cannot read team Gist: {remote}"}
206
+
207
+ try:
208
+ gist_content = remote["files"][TEAM_GIST_FILENAME]["content"]
209
+ remote_data = json.loads(gist_content)
210
+ except (KeyError, json.JSONDecodeError) as e:
211
+ # First push — Gist file may not exist yet
212
+ remote_data = {"version": 1, "profiles": {}, "members": {}}
213
+
214
+ remote_profiles = remote_data.get("profiles", {})
215
+ members = remote_data.get("members", {})
216
+
217
+ local_profiles = load_profiles()
218
+ result_info = {"pushed": 0, "pulled": 0, "conflicts": []}
219
+
220
+ # 2. Push: merge local into remote (local overrides on conflict)
221
+ if push:
222
+ merged_remote = {**remote_profiles, **local_profiles}
223
+ result_info["pushed"] = len(local_profiles)
224
+ else:
225
+ merged_remote = remote_profiles
226
+
227
+ # 3. Pull: add remote profiles that don't exist locally
228
+ if pull:
229
+ new_to_local = {k: v for k, v in remote_profiles.items() if k not in local_profiles}
230
+ updated_local = {**local_profiles, **new_to_local}
231
+ result_info["pulled"] = len(new_to_local)
232
+ save_profiles(updated_local)
233
+
234
+ # 4. Update member presence in Gist
235
+ members[machine] = {
236
+ "last_seen": time.time(),
237
+ "profiles_contributed": len(local_profiles),
238
+ }
239
+
240
+ # 5. Write merged profiles + updated members back to Gist
241
+ new_gist_data = {
242
+ "version": 1,
243
+ "profiles": merged_remote,
244
+ "members": members,
245
+ "updated_at": time.time(),
246
+ }
247
+
248
+ patch_result = _gist_request(
249
+ gist_url, method="PATCH", token=token,
250
+ payload={"files": {TEAM_GIST_FILENAME: {"content": json.dumps(new_gist_data, indent=2)}}}
251
+ )
252
+
253
+ if not patch_result or "error" in patch_result:
254
+ return {"success": False, "message": f"Gist write failed: {patch_result}"}
255
+
256
+ config["last_sync"] = time.time()
257
+ _save_config(config)
258
+
259
+ return {
260
+ "success": True,
261
+ "pushed": result_info["pushed"],
262
+ "pulled": result_info["pulled"],
263
+ "team_members": len(members),
264
+ "total_profiles": len(merged_remote),
265
+ "message": (
266
+ f"Synced: pushed {result_info['pushed']}, pulled {result_info['pulled']} profiles. "
267
+ f"{len(members)} team member(s) active."
268
+ ),
269
+ }
270
+
271
+
272
+ def get_team_status() -> dict:
273
+ """Return team sync configuration and member list."""
274
+ config = _load_config()
275
+ if not config.get("gist_id"):
276
+ return {"configured": False}
277
+
278
+ gist_url_api = f"{GIST_API}/{config['gist_id']}"
279
+ remote = _gist_request(gist_url_api, token=config["github_token"])
280
+ if not remote or "error" in remote:
281
+ return {
282
+ "configured": True,
283
+ "gist_id": config["gist_id"],
284
+ "gist_url": config.get("gist_url"),
285
+ "last_sync": config.get("last_sync"),
286
+ "members": [],
287
+ "error": "Cannot reach team Gist",
288
+ }
289
+
290
+ try:
291
+ gist_content = remote["files"][TEAM_GIST_FILENAME]["content"]
292
+ data = json.loads(gist_content)
293
+ members = data.get("members", {})
294
+ except Exception:
295
+ members = {}
296
+
297
+ return {
298
+ "configured": True,
299
+ "gist_id": config["gist_id"],
300
+ "gist_url": config.get("gist_url"),
301
+ "last_sync": config.get("last_sync"),
302
+ "machine": _machine_id(),
303
+ "is_admin": config.get("is_admin", False),
304
+ "total_profiles": len(data.get("profiles", {})) if "data" in dir() else 0,
305
+ "members": [
306
+ {
307
+ "machine": k,
308
+ "last_seen": v.get("last_seen"),
309
+ "profiles": v.get("profiles_contributed", 0),
310
+ "active": (time.time() - v.get("last_seen", 0)) < 86400 * 7,
311
+ }
312
+ for k, v in members.items()
313
+ ],
314
+ }
315
+
316
+
317
+ # ---------------------------------------------------------------------------
318
+ # Slack alerts
319
+ # ---------------------------------------------------------------------------
320
+
321
+ def setup_slack(webhook_url: str, threshold_pct: float = 80.0) -> dict:
322
+ """Store Slack webhook for waste/context alerts."""
323
+ config = _load_config()
324
+ config["slack_webhook"] = webhook_url
325
+ config["slack_threshold_pct"] = threshold_pct
326
+ _save_config(config)
327
+ return {"success": True, "message": f"Slack webhook saved. Alert threshold: {threshold_pct}% context usage."}
328
+
329
+
330
+ def _post_slack(webhook_url: str, payload: dict) -> bool:
331
+ try:
332
+ data = json.dumps(payload).encode("utf-8")
333
+ req = urllib.request.Request(
334
+ webhook_url,
335
+ data=data,
336
+ headers={"Content-Type": "application/json"},
337
+ method="POST",
338
+ )
339
+ with urllib.request.urlopen(req, timeout=10) as resp:
340
+ return resp.status == 200
341
+ except Exception:
342
+ return False
343
+
344
+
345
+ def send_slack_test(webhook_url: Optional[str] = None) -> dict:
346
+ """Send a test message to Slack to verify the webhook works."""
347
+ config = _load_config()
348
+ url = webhook_url or config.get("slack_webhook")
349
+ if not url:
350
+ return {"success": False, "message": "No Slack webhook configured."}
351
+
352
+ ok = _post_slack(url, {
353
+ "text": ":white_check_mark: MCPSwitch team alert test — webhook is working.",
354
+ "blocks": [
355
+ {
356
+ "type": "section",
357
+ "text": {
358
+ "type": "mrkdwn",
359
+ "text": "*MCPSwitch* | Test alert\n:white_check_mark: Webhook configured correctly.",
360
+ },
361
+ }
362
+ ],
363
+ })
364
+ return {"success": ok, "message": "Test sent." if ok else "Slack delivery failed."}
365
+
366
+
367
+ def check_and_alert(force: bool = False) -> dict:
368
+ """Check current context usage and send Slack alert if over threshold."""
369
+ config = _load_config()
370
+ if not config.get("slack_webhook"):
371
+ return {"success": False, "message": "No Slack webhook configured."}
372
+
373
+ from .config import get_claude_code_config_path, get_all_mcp_servers
374
+ from .tokens import estimate_total_tokens
375
+
376
+ servers = get_all_mcp_servers(get_claude_code_config_path())
377
+ if not servers:
378
+ return {"success": True, "alerted": False, "message": "No MCP servers configured."}
379
+
380
+ est = estimate_total_tokens(servers)
381
+ ctx_pct = est.get("context_pct", 0)
382
+ threshold = config.get("slack_threshold_pct", 80.0)
383
+
384
+ if ctx_pct < threshold and not force:
385
+ return {"success": True, "alerted": False,
386
+ "message": f"Context at {ctx_pct:.1f}% — below threshold ({threshold}%)."}
387
+
388
+ token = est.get("total", 0)
389
+ blocks = [
390
+ {
391
+ "type": "header",
392
+ "text": {"type": "plain_text", "text": ":warning: MCPSwitch Context Alert"},
393
+ },
394
+ {
395
+ "type": "section",
396
+ "fields": [
397
+ {"type": "mrkdwn", "text": f"*Context used:*\n{ctx_pct:.1f}% ({token:,} tokens)"},
398
+ {"type": "mrkdwn", "text": f"*Servers loaded:*\n{len(servers)}"},
399
+ {"type": "mrkdwn", "text": f"*Threshold:*\n{threshold:.0f}%"},
400
+ {"type": "mrkdwn", "text": f"*Machine:*\n{_machine_id()}"},
401
+ ],
402
+ },
403
+ {
404
+ "type": "section",
405
+ "text": {"type": "mrkdwn",
406
+ "text": "Run `mcpswitch waste --fix` or switch to a lighter profile."},
407
+ },
408
+ ]
409
+
410
+ ok = _post_slack(config["slack_webhook"], {"blocks": blocks})
411
+ return {"success": ok, "alerted": True, "context_pct": ctx_pct,
412
+ "message": "Alert sent." if ok else "Slack delivery failed."}
413
+
414
+
415
+ # ---------------------------------------------------------------------------
416
+ # Team analytics
417
+ # ---------------------------------------------------------------------------
418
+
419
+ def get_team_report(days: int = 30) -> dict:
420
+ """Aggregate local usage stats for the team report.
421
+
422
+ In a full Team product this would collect stats from all members via the
423
+ shared Gist's analytics file. For now, it returns local stats with a
424
+ structure ready for future aggregation.
425
+ """
426
+ from .usage import get_usage_summary, get_waste_report, get_server_usage_stats
427
+ from .config import get_claude_code_config_path, get_all_mcp_servers
428
+
429
+ summary = get_usage_summary(days=days)
430
+ servers = get_all_mcp_servers(get_claude_code_config_path())
431
+ waste = get_waste_report(list(servers.keys()), days=days) if servers else []
432
+ server_stats = get_server_usage_stats(days=days)
433
+
434
+ return {
435
+ "machine": _machine_id(),
436
+ "period_days": days,
437
+ "total_calls": summary.get("total_calls", 0),
438
+ "unique_servers": summary.get("unique_servers", 0),
439
+ "top_servers": summary.get("top_servers", []),
440
+ "server_stats": server_stats,
441
+ "waste_count": len(waste),
442
+ "waste_servers": waste,
443
+ "active_servers": len(servers),
444
+ "generated_at": time.time(),
445
+ }
446
+
447
+
448
+ def push_analytics_to_gist() -> dict:
449
+ """Upload this machine's analytics to the shared team Gist."""
450
+ config = _load_config()
451
+ if not config.get("gist_id"):
452
+ return {"success": False, "message": "Team not configured."}
453
+
454
+ report = get_team_report(days=30)
455
+ gist_url_api = f"{GIST_API}/{config['gist_id']}"
456
+ token = config["github_token"]
457
+ machine = _machine_id()
458
+
459
+ # Read current analytics file
460
+ remote = _gist_request(gist_url_api, token=token)
461
+ if not remote or "error" in remote:
462
+ return {"success": False, "message": f"Cannot read team Gist: {remote}"}
463
+
464
+ try:
465
+ analytics_content = remote["files"].get(TEAM_ANALYTICS_FILENAME, {}).get("content", "{}")
466
+ analytics = json.loads(analytics_content)
467
+ except Exception:
468
+ analytics = {"version": 1, "member_stats": {}}
469
+
470
+ analytics["member_stats"][machine] = report
471
+ analytics["updated_at"] = time.time()
472
+
473
+ result = _gist_request(
474
+ gist_url_api, method="PATCH", token=token,
475
+ payload={"files": {TEAM_ANALYTICS_FILENAME: {"content": json.dumps(analytics, indent=2)}}}
476
+ )
477
+
478
+ if not result or "error" in result:
479
+ return {"success": False, "message": f"Analytics push failed: {result}"}
480
+
481
+ return {"success": True, "message": f"Analytics uploaded for {machine}."}
482
+
483
+
484
+ def get_full_team_analytics() -> dict:
485
+ """Download and aggregate analytics from all team members."""
486
+ config = _load_config()
487
+ if not config.get("gist_id"):
488
+ return {"success": False, "message": "Team not configured."}
489
+
490
+ gist_url_api = f"{GIST_API}/{config['gist_id']}"
491
+ remote = _gist_request(gist_url_api, token=config["github_token"])
492
+ if not remote or "error" in remote:
493
+ return {"success": False, "message": f"Cannot read team Gist: {remote}"}
494
+
495
+ try:
496
+ analytics_content = remote["files"][TEAM_ANALYTICS_FILENAME]["content"]
497
+ analytics = json.loads(analytics_content)
498
+ except Exception:
499
+ return {"success": False, "message": "No team analytics data yet."}
500
+
501
+ member_stats = analytics.get("member_stats", {})
502
+
503
+ # Aggregate across all members
504
+ total_calls = sum(m.get("total_calls", 0) for m in member_stats.values())
505
+ all_waste = []
506
+ server_call_totals: dict = {}
507
+
508
+ for member_data in member_stats.values():
509
+ for w in member_data.get("waste_servers", []):
510
+ all_waste.append(w)
511
+ for s in member_data.get("top_servers", []):
512
+ srv = s["server"]
513
+ server_call_totals[srv] = server_call_totals.get(srv, 0) + s["calls"]
514
+
515
+ top_servers = sorted(
516
+ [{"server": k, "calls": v} for k, v in server_call_totals.items()],
517
+ key=lambda x: x["calls"],
518
+ reverse=True,
519
+ )[:10]
520
+
521
+ # Deduplicate waste by server name, keep highest level
522
+ waste_map: dict = {}
523
+ for w in all_waste:
524
+ srv = w["server"]
525
+ if srv not in waste_map or w.get("total_calls", 0) > waste_map[srv].get("total_calls", 0):
526
+ waste_map[srv] = w
527
+
528
+ return {
529
+ "success": True,
530
+ "member_count": len(member_stats),
531
+ "members": list(member_stats.keys()),
532
+ "total_calls": total_calls,
533
+ "top_servers_across_team": top_servers,
534
+ "waste_count": len(waste_map),
535
+ "waste_servers": list(waste_map.values()),
536
+ "updated_at": analytics.get("updated_at"),
537
+ }
538
+
539
+
540
+ # ---------------------------------------------------------------------------
541
+ # Slack monthly report
542
+ # ---------------------------------------------------------------------------
543
+
544
+ def send_monthly_report_to_slack() -> dict:
545
+ """Build a team analytics report and post it to Slack."""
546
+ config = _load_config()
547
+ if not config.get("slack_webhook"):
548
+ return {"success": False, "message": "No Slack webhook configured."}
549
+
550
+ data = get_full_team_analytics()
551
+ if not data.get("success"):
552
+ # Fall back to local-only report
553
+ data = get_team_report(days=30)
554
+ data["member_count"] = 1
555
+ data["top_servers_across_team"] = data.get("top_servers", [])
556
+
557
+ top_text = "\n".join(
558
+ f" - *{s['server']}*: {s['calls']} calls"
559
+ for s in data.get("top_servers_across_team", [])[:5]
560
+ ) or " No usage data."
561
+
562
+ waste_text = (
563
+ "\n".join(f" - *{w['server']}* ({w.get('waste_level','?')})"
564
+ for w in data.get("waste_servers", [])[:5])
565
+ if data.get("waste_count", 0) > 0
566
+ else " No waste detected."
567
+ )
568
+
569
+ blocks = [
570
+ {"type": "header",
571
+ "text": {"type": "plain_text", "text": ":bar_chart: MCPSwitch Team Monthly Report"}},
572
+ {"type": "section",
573
+ "fields": [
574
+ {"type": "mrkdwn", "text": f"*Team members:*\n{data.get('member_count', 1)}"},
575
+ {"type": "mrkdwn", "text": f"*Total tool calls (30d):*\n{data.get('total_calls', 0):,}"},
576
+ {"type": "mrkdwn", "text": f"*Waste alerts:*\n{data.get('waste_count', 0)}"},
577
+ ]},
578
+ {"type": "section",
579
+ "text": {"type": "mrkdwn", "text": f"*Top Servers (team-wide):*\n{top_text}"}},
580
+ {"type": "section",
581
+ "text": {"type": "mrkdwn", "text": f"*Waste Alerts:*\n{waste_text}"}},
582
+ {"type": "context",
583
+ "elements": [{"type": "mrkdwn",
584
+ "text": "Fix waste with `mcpswitch waste --fix` | MCPSwitch Team"}]},
585
+ ]
586
+
587
+ ok = _post_slack(config["slack_webhook"], {"blocks": blocks})
588
+ return {"success": ok, "message": "Monthly report sent to Slack." if ok else "Slack delivery failed."}