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/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."}
|