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/__init__.py
ADDED
mcpswitch/auto.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Context-aware automatic profile selection.
|
|
2
|
+
|
|
3
|
+
How it works:
|
|
4
|
+
1. Read conversation context from ~/.claude/history.jsonl (last N messages)
|
|
5
|
+
2. Scan project directory for tech stack signals
|
|
6
|
+
3. Score each profile against detected context
|
|
7
|
+
4. Switch to the best match — silently if confident, ask if not
|
|
8
|
+
|
|
9
|
+
This runs at session start via: mcpswitch auto
|
|
10
|
+
Or manually: mcpswitch auto --dir /path/to/project
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from .profiles import load_profiles, get_active_profile, set_active_profile
|
|
20
|
+
from .config import get_claude_code_config_path, set_mcp_servers
|
|
21
|
+
from .tokens import estimate_total_tokens
|
|
22
|
+
|
|
23
|
+
# ── Tech stack signals → MCP server names ────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
# File presence signals: if these files exist, these servers are likely needed
|
|
26
|
+
FILE_SIGNALS: list[tuple[str, list[str]]] = [
|
|
27
|
+
# (glob pattern, [relevant mcp server names])
|
|
28
|
+
("package.json", ["github", "playwright", "context7"]),
|
|
29
|
+
("requirements.txt", ["github", "context7", "postgres"]),
|
|
30
|
+
("pyproject.toml", ["github", "context7", "postgres"]),
|
|
31
|
+
("go.mod", ["github", "context7"]),
|
|
32
|
+
("Cargo.toml", ["github", "context7"]),
|
|
33
|
+
("docker-compose.yml", ["github", "postgres", "sqlite"]),
|
|
34
|
+
(".github/workflows/*.yml",["github"]),
|
|
35
|
+
("prisma/schema.prisma", ["github", "postgres"]),
|
|
36
|
+
("supabase/**", ["github", "postgres"]),
|
|
37
|
+
("*.test.*", ["playwright", "github"]),
|
|
38
|
+
("playwright.config.*", ["playwright"]),
|
|
39
|
+
("*.md", ["filesystem", "fetch"]),
|
|
40
|
+
("docs/**", ["filesystem", "fetch"]),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
# Conversation keyword signals: words in recent messages → relevant servers
|
|
44
|
+
KEYWORD_SIGNALS: dict[str, list[str]] = {
|
|
45
|
+
# Code & git
|
|
46
|
+
"pull request": ["github"],
|
|
47
|
+
"pr review": ["github"],
|
|
48
|
+
"github": ["github"],
|
|
49
|
+
"commit": ["github"],
|
|
50
|
+
"branch": ["github"],
|
|
51
|
+
"merge": ["github"],
|
|
52
|
+
|
|
53
|
+
# Browser / frontend
|
|
54
|
+
"browser": ["playwright"],
|
|
55
|
+
"screenshot": ["playwright"],
|
|
56
|
+
"click": ["playwright"],
|
|
57
|
+
"scrape": ["playwright"],
|
|
58
|
+
"selenium": ["playwright"],
|
|
59
|
+
"test": ["playwright", "github"],
|
|
60
|
+
|
|
61
|
+
# Search
|
|
62
|
+
"search": ["brave-search"],
|
|
63
|
+
"google": ["brave-search"],
|
|
64
|
+
"find online": ["brave-search"],
|
|
65
|
+
"look up": ["brave-search"],
|
|
66
|
+
|
|
67
|
+
# Database
|
|
68
|
+
"database": ["postgres", "sqlite"],
|
|
69
|
+
"sql": ["postgres", "sqlite"],
|
|
70
|
+
"postgres": ["postgres"],
|
|
71
|
+
"sqlite": ["sqlite"],
|
|
72
|
+
"query": ["postgres", "sqlite"],
|
|
73
|
+
|
|
74
|
+
# Docs / files
|
|
75
|
+
"documentation": ["filesystem", "context7"],
|
|
76
|
+
"readme": ["filesystem"],
|
|
77
|
+
"file": ["filesystem"],
|
|
78
|
+
"read file": ["filesystem"],
|
|
79
|
+
|
|
80
|
+
# API docs
|
|
81
|
+
"api docs": ["context7"],
|
|
82
|
+
"documentation": ["context7"],
|
|
83
|
+
"latest docs": ["context7"],
|
|
84
|
+
"how to use": ["context7"],
|
|
85
|
+
|
|
86
|
+
# Notes / knowledge
|
|
87
|
+
"obsidian": ["obsidian"],
|
|
88
|
+
"notes": ["obsidian"],
|
|
89
|
+
"vault": ["obsidian"],
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
CONFIDENCE_THRESHOLD = 0.4 # switch silently above this, ask below
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _read_recent_history(n_messages: int = 20) -> str:
|
|
96
|
+
"""Read the last N messages from Claude Code history.
|
|
97
|
+
|
|
98
|
+
Returns concatenated text of recent user messages.
|
|
99
|
+
"""
|
|
100
|
+
history_file = Path.home() / ".claude" / "history.jsonl"
|
|
101
|
+
if not history_file.exists():
|
|
102
|
+
return ""
|
|
103
|
+
|
|
104
|
+
lines = []
|
|
105
|
+
try:
|
|
106
|
+
with open(history_file, "r", encoding="utf-8") as f:
|
|
107
|
+
lines = f.readlines()
|
|
108
|
+
except Exception:
|
|
109
|
+
return ""
|
|
110
|
+
|
|
111
|
+
messages = []
|
|
112
|
+
for line in reversed(lines[-200:]): # look at last 200 lines max
|
|
113
|
+
try:
|
|
114
|
+
entry = json.loads(line.strip())
|
|
115
|
+
# Extract user message text
|
|
116
|
+
if isinstance(entry, dict):
|
|
117
|
+
content = entry.get("message", "") or entry.get("content", "")
|
|
118
|
+
if isinstance(content, list):
|
|
119
|
+
for block in content:
|
|
120
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
121
|
+
messages.append(block.get("text", ""))
|
|
122
|
+
elif isinstance(content, str):
|
|
123
|
+
messages.append(content)
|
|
124
|
+
except Exception:
|
|
125
|
+
continue
|
|
126
|
+
if len(messages) >= n_messages:
|
|
127
|
+
break
|
|
128
|
+
|
|
129
|
+
return " ".join(messages).lower()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _scan_project_signals(directory: str) -> list[str]:
|
|
133
|
+
"""Scan directory for tech stack files. Returns list of relevant MCP server names."""
|
|
134
|
+
path = Path(directory)
|
|
135
|
+
if not path.exists():
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
relevant_servers = []
|
|
139
|
+
for pattern, servers in FILE_SIGNALS:
|
|
140
|
+
if "**" in pattern or "*" in pattern:
|
|
141
|
+
matches = list(path.glob(pattern))
|
|
142
|
+
else:
|
|
143
|
+
matches = [path / pattern] if (path / pattern).exists() else []
|
|
144
|
+
|
|
145
|
+
if matches:
|
|
146
|
+
relevant_servers.extend(servers)
|
|
147
|
+
|
|
148
|
+
return list(set(relevant_servers))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _score_profile(
|
|
152
|
+
profile_name: str,
|
|
153
|
+
profile_servers: dict,
|
|
154
|
+
context_servers: list[str],
|
|
155
|
+
conversation_servers: list[str],
|
|
156
|
+
) -> float:
|
|
157
|
+
"""Score a profile 0.0–1.0 based on how well it matches detected context.
|
|
158
|
+
|
|
159
|
+
Higher = better match.
|
|
160
|
+
"""
|
|
161
|
+
if not profile_servers:
|
|
162
|
+
return 0.0
|
|
163
|
+
|
|
164
|
+
loaded = set(profile_servers.keys())
|
|
165
|
+
needed = set(context_servers + conversation_servers)
|
|
166
|
+
|
|
167
|
+
if not needed:
|
|
168
|
+
return 0.0
|
|
169
|
+
|
|
170
|
+
# Coverage: what % of needed servers does this profile have?
|
|
171
|
+
coverage = len(loaded & needed) / len(needed)
|
|
172
|
+
|
|
173
|
+
# Precision: what % of loaded servers are actually needed? (penalize bloat)
|
|
174
|
+
precision = len(loaded & needed) / len(loaded) if loaded else 0.0
|
|
175
|
+
|
|
176
|
+
# Weighted: coverage matters more than precision
|
|
177
|
+
score = (coverage * 0.7) + (precision * 0.3)
|
|
178
|
+
return round(score, 3)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def select_best_profile(
|
|
182
|
+
directory: Optional[str] = None,
|
|
183
|
+
conversation_text: Optional[str] = None,
|
|
184
|
+
) -> dict:
|
|
185
|
+
"""Select the best profile for current context.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
{
|
|
189
|
+
"recommended": str, # profile name
|
|
190
|
+
"confidence": float, # 0.0 - 1.0
|
|
191
|
+
"reason": str, # human-readable explanation
|
|
192
|
+
"scores": {name: score}, # all profile scores
|
|
193
|
+
"context_servers": [...],# servers detected from project
|
|
194
|
+
"conversation_servers":[],# servers detected from conversation
|
|
195
|
+
"current_profile": str,
|
|
196
|
+
}
|
|
197
|
+
"""
|
|
198
|
+
profiles = load_profiles()
|
|
199
|
+
current = get_active_profile()
|
|
200
|
+
|
|
201
|
+
if not profiles:
|
|
202
|
+
return {
|
|
203
|
+
"recommended": None,
|
|
204
|
+
"confidence": 0.0,
|
|
205
|
+
"reason": "No profiles configured. Run: mcpswitch import --name <name>",
|
|
206
|
+
"scores": {},
|
|
207
|
+
"context_servers": [],
|
|
208
|
+
"conversation_servers": [],
|
|
209
|
+
"current_profile": current,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
# 1. Detect from project files
|
|
213
|
+
project_dir = directory or os.getcwd()
|
|
214
|
+
context_servers = _scan_project_signals(project_dir)
|
|
215
|
+
|
|
216
|
+
# 2. Detect from conversation history
|
|
217
|
+
if conversation_text is None:
|
|
218
|
+
conversation_text = _read_recent_history(20)
|
|
219
|
+
|
|
220
|
+
conversation_servers = []
|
|
221
|
+
for keyword, servers in KEYWORD_SIGNALS.items():
|
|
222
|
+
if keyword in conversation_text:
|
|
223
|
+
conversation_servers.extend(servers)
|
|
224
|
+
conversation_servers = list(set(conversation_servers))
|
|
225
|
+
|
|
226
|
+
# 3. Score all profiles
|
|
227
|
+
scores = {}
|
|
228
|
+
for name, servers in profiles.items():
|
|
229
|
+
scores[name] = _score_profile(name, servers, context_servers, conversation_servers)
|
|
230
|
+
|
|
231
|
+
if not any(scores.values()):
|
|
232
|
+
# No signals detected — recommend the profile with fewest tokens (lean default)
|
|
233
|
+
config_path = get_claude_code_config_path()
|
|
234
|
+
best = min(profiles.keys(), key=lambda n: estimate_total_tokens(profiles[n])["total"])
|
|
235
|
+
return {
|
|
236
|
+
"recommended": best,
|
|
237
|
+
"confidence": 0.2,
|
|
238
|
+
"reason": "No project signals detected. Recommending leanest profile.",
|
|
239
|
+
"scores": scores,
|
|
240
|
+
"context_servers": context_servers,
|
|
241
|
+
"conversation_servers": conversation_servers,
|
|
242
|
+
"current_profile": current,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
best = max(scores, key=lambda k: scores[k])
|
|
246
|
+
confidence = scores[best]
|
|
247
|
+
|
|
248
|
+
# Build reason string
|
|
249
|
+
reason_parts = []
|
|
250
|
+
if context_servers:
|
|
251
|
+
reason_parts.append(f"project signals: {', '.join(context_servers[:3])}")
|
|
252
|
+
if conversation_servers:
|
|
253
|
+
reason_parts.append(f"conversation context: {', '.join(conversation_servers[:3])}")
|
|
254
|
+
reason = "Detected " + " | ".join(reason_parts) if reason_parts else "Best available match"
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
"recommended": best,
|
|
258
|
+
"confidence": confidence,
|
|
259
|
+
"current_profile": current,
|
|
260
|
+
"scores": scores,
|
|
261
|
+
"context_servers": context_servers,
|
|
262
|
+
"conversation_servers": conversation_servers,
|
|
263
|
+
"reason": reason,
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def auto_switch(
|
|
268
|
+
directory: Optional[str] = None,
|
|
269
|
+
confirm_threshold: float = CONFIDENCE_THRESHOLD,
|
|
270
|
+
force: bool = False,
|
|
271
|
+
silent: bool = False,
|
|
272
|
+
) -> dict:
|
|
273
|
+
"""Auto-select and switch profile based on context.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
{
|
|
277
|
+
"switched": bool,
|
|
278
|
+
"from_profile": str,
|
|
279
|
+
"to_profile": str,
|
|
280
|
+
"confidence": float,
|
|
281
|
+
"needs_confirmation": bool, # True if confidence below threshold
|
|
282
|
+
"reason": str,
|
|
283
|
+
}
|
|
284
|
+
"""
|
|
285
|
+
result = select_best_profile(directory=directory)
|
|
286
|
+
recommended = result["recommended"]
|
|
287
|
+
confidence = result["confidence"]
|
|
288
|
+
current = result["current_profile"]
|
|
289
|
+
|
|
290
|
+
if not recommended:
|
|
291
|
+
return {
|
|
292
|
+
"switched": False,
|
|
293
|
+
"from_profile": current,
|
|
294
|
+
"to_profile": None,
|
|
295
|
+
"confidence": 0.0,
|
|
296
|
+
"needs_confirmation": False,
|
|
297
|
+
"reason": result["reason"],
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
# Already on the best profile
|
|
301
|
+
if recommended == current and not force:
|
|
302
|
+
return {
|
|
303
|
+
"switched": False,
|
|
304
|
+
"from_profile": current,
|
|
305
|
+
"to_profile": recommended,
|
|
306
|
+
"confidence": confidence,
|
|
307
|
+
"needs_confirmation": False,
|
|
308
|
+
"reason": f"Already on best profile '{current}'",
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
# Low confidence — ask user
|
|
312
|
+
if confidence < confirm_threshold and not force:
|
|
313
|
+
return {
|
|
314
|
+
"switched": False,
|
|
315
|
+
"from_profile": current,
|
|
316
|
+
"to_profile": recommended,
|
|
317
|
+
"confidence": confidence,
|
|
318
|
+
"needs_confirmation": True,
|
|
319
|
+
"reason": result["reason"],
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
# High confidence — switch silently
|
|
323
|
+
from .profiles import get_profile
|
|
324
|
+
from .config import get_claude_desktop_config_path
|
|
325
|
+
|
|
326
|
+
servers = get_profile(recommended)
|
|
327
|
+
if not servers and servers != {}:
|
|
328
|
+
return {
|
|
329
|
+
"switched": False,
|
|
330
|
+
"from_profile": current,
|
|
331
|
+
"to_profile": recommended,
|
|
332
|
+
"confidence": confidence,
|
|
333
|
+
"needs_confirmation": False,
|
|
334
|
+
"reason": f"Profile '{recommended}' not found",
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
config_path = get_claude_code_config_path()
|
|
338
|
+
set_mcp_servers(config_path, servers)
|
|
339
|
+
|
|
340
|
+
desktop_path = get_claude_desktop_config_path()
|
|
341
|
+
if desktop_path and desktop_path.exists():
|
|
342
|
+
set_mcp_servers(desktop_path, servers)
|
|
343
|
+
|
|
344
|
+
set_active_profile(recommended)
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
"switched": True,
|
|
348
|
+
"from_profile": current,
|
|
349
|
+
"to_profile": recommended,
|
|
350
|
+
"confidence": confidence,
|
|
351
|
+
"needs_confirmation": False,
|
|
352
|
+
"reason": result["reason"],
|
|
353
|
+
}
|
mcpswitch/billing.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Billing and license-key management for MCPSwitch — Team tier only.
|
|
3
|
+
|
|
4
|
+
Payment processor: Lemon Squeezy
|
|
5
|
+
Store: mcpswitch.lemonsqueezy.com
|
|
6
|
+
Product: MCPSwitch Team — ₹499/month subscription
|
|
7
|
+
|
|
8
|
+
License key format
|
|
9
|
+
------------------
|
|
10
|
+
MCPS-TEAM-{PAYLOAD}-{SIG}
|
|
11
|
+
|
|
12
|
+
PAYLOAD : Base32-encoded JSON {"e": email_sha256[:16], "t": unix_ts, "s": seats}
|
|
13
|
+
SIG : Base32-encoded first 10 bytes of HMAC-SHA256(secret, PAYLOAD)
|
|
14
|
+
|
|
15
|
+
Keys are validated offline — no network call required.
|
|
16
|
+
The same HMAC secret must be set in both the CLI build and the webhook server
|
|
17
|
+
via the MCPSWITCH_LICENSE_SECRET environment variable.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import base64
|
|
21
|
+
import hashlib
|
|
22
|
+
import hmac
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import time
|
|
26
|
+
import webbrowser
|
|
27
|
+
|
|
28
|
+
# ── HMAC secret ───────────────────────────────────────────────────────────────
|
|
29
|
+
# Must match in both the CLI build and the webhook server.
|
|
30
|
+
# Set MCPSWITCH_LICENSE_SECRET in your Railway environment variables.
|
|
31
|
+
_DEFAULT_SECRET = "mcpswitch-change-me-in-production-v1"
|
|
32
|
+
_HMAC_SECRET: bytes = os.environ.get(
|
|
33
|
+
"MCPSWITCH_LICENSE_SECRET", _DEFAULT_SECRET
|
|
34
|
+
).encode()
|
|
35
|
+
|
|
36
|
+
# ── Lemon Squeezy config ──────────────────────────────────────────────────────
|
|
37
|
+
LEMONSQUEEZY_API_KEY = os.environ.get("LEMONSQUEEZY_API_KEY", "")
|
|
38
|
+
LEMONSQUEEZY_WEBHOOK_SECRET = os.environ.get("LEMONSQUEEZY_WEBHOOK_SECRET", "")
|
|
39
|
+
LEMONSQUEEZY_STORE_ID = os.environ.get("LEMONSQUEEZY_STORE_ID", "")
|
|
40
|
+
LEMONSQUEEZY_VARIANT_ID = os.environ.get("LEMONSQUEEZY_VARIANT_ID", "1525680")
|
|
41
|
+
LEMONSQUEEZY_PRODUCT_ID = os.environ.get("LEMONSQUEEZY_PRODUCT_ID", "971803")
|
|
42
|
+
|
|
43
|
+
# Checkout URL — Lemon Squeezy hosted checkout for the Team variant
|
|
44
|
+
TEAM_PAYMENT_URL = os.environ.get(
|
|
45
|
+
"MCPSWITCH_TEAM_URL",
|
|
46
|
+
f"https://mcpswitch.lemonsqueezy.com/buy/{LEMONSQUEEZY_VARIANT_ID}",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ── Base32 helpers (no padding) ───────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
def _b32_encode(data: bytes) -> str:
|
|
53
|
+
return base64.b32encode(data).decode().rstrip("=")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _b32_decode(s: str) -> bytes:
|
|
57
|
+
s = s.upper()
|
|
58
|
+
pad = (8 - len(s) % 8) % 8
|
|
59
|
+
return base64.b32decode(s + "=" * pad)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── License key generation ────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
def generate_license_key(email: str, seats: int = 1) -> str:
|
|
65
|
+
"""Generate a cryptographically signed Team license key.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
email: Buyer's email address. Stored as a truncated SHA-256 hash —
|
|
69
|
+
never in plaintext.
|
|
70
|
+
seats: Number of seats included in the purchase.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
A key string like ``MCPS-TEAM-MNQWI...-ABCDE...``
|
|
74
|
+
"""
|
|
75
|
+
email_hash = hashlib.sha256(email.lower().strip().encode()).hexdigest()[:16]
|
|
76
|
+
issued_at = int(time.time())
|
|
77
|
+
payload_json = json.dumps(
|
|
78
|
+
{"e": email_hash, "t": issued_at, "s": seats},
|
|
79
|
+
separators=(",", ":"),
|
|
80
|
+
)
|
|
81
|
+
payload = _b32_encode(payload_json.encode())
|
|
82
|
+
sig_raw = hmac.digest(_HMAC_SECRET, payload.encode(), "sha256")[:10]
|
|
83
|
+
sig = _b32_encode(sig_raw)
|
|
84
|
+
return f"MCPS-TEAM-{payload}-{sig}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ── License key validation ────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
def validate_license_key(key: str) -> dict:
|
|
90
|
+
"""Validate a Team license key offline using HMAC.
|
|
91
|
+
|
|
92
|
+
Returns on success::
|
|
93
|
+
|
|
94
|
+
{"valid": True, "tier": "team", "issued_at": int, "seats": int, "email_hash": str}
|
|
95
|
+
|
|
96
|
+
Returns on failure::
|
|
97
|
+
|
|
98
|
+
{"valid": False, "error": "<reason>"}
|
|
99
|
+
"""
|
|
100
|
+
key = key.strip().upper()
|
|
101
|
+
|
|
102
|
+
parts = key.split("-", 3)
|
|
103
|
+
if len(parts) != 4 or parts[0] != "MCPS" or parts[1] != "TEAM":
|
|
104
|
+
return {"valid": False, "error": "Invalid key format — expected MCPS-TEAM-... key"}
|
|
105
|
+
|
|
106
|
+
_, _tier, payload, claimed_sig = parts
|
|
107
|
+
|
|
108
|
+
expected_raw = hmac.digest(_HMAC_SECRET, payload.encode(), "sha256")[:10]
|
|
109
|
+
expected_sig = _b32_encode(expected_raw)
|
|
110
|
+
|
|
111
|
+
if not hmac.compare_digest(expected_sig, claimed_sig):
|
|
112
|
+
return {"valid": False, "error": "Key signature is invalid"}
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
meta = json.loads(_b32_decode(payload).decode())
|
|
116
|
+
except Exception:
|
|
117
|
+
return {"valid": False, "error": "Key payload is corrupted"}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
"valid": True,
|
|
121
|
+
"tier": "team",
|
|
122
|
+
"issued_at": meta.get("t", 0),
|
|
123
|
+
"seats": meta.get("s", 1),
|
|
124
|
+
"email_hash": meta.get("e", ""),
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ── Lemon Squeezy checkout ────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
def open_checkout() -> str:
|
|
131
|
+
"""Open the Lemon Squeezy checkout page for the Team tier in the browser.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
The URL that was opened.
|
|
135
|
+
"""
|
|
136
|
+
webbrowser.open(TEAM_PAYMENT_URL)
|
|
137
|
+
return TEAM_PAYMENT_URL
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ── Lemon Squeezy webhook verification ───────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
def verify_lemonsqueezy_webhook(payload: bytes, sig_header: str) -> dict:
|
|
143
|
+
"""Verify a Lemon Squeezy webhook signature and return the parsed event.
|
|
144
|
+
|
|
145
|
+
Lemon Squeezy signs webhooks with HMAC-SHA256.
|
|
146
|
+
The signature is in the X-Signature header as a hex digest.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
payload: Raw request body bytes.
|
|
150
|
+
sig_header: Value of the ``X-Signature`` HTTP header.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Parsed event dict.
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
ValueError: If the webhook secret is not set or signature is invalid.
|
|
157
|
+
"""
|
|
158
|
+
if not LEMONSQUEEZY_WEBHOOK_SECRET:
|
|
159
|
+
raise ValueError(
|
|
160
|
+
"LEMONSQUEEZY_WEBHOOK_SECRET is not configured. "
|
|
161
|
+
"Set it to your webhook signing secret from the Lemon Squeezy Dashboard."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
expected = hmac.digest(
|
|
165
|
+
LEMONSQUEEZY_WEBHOOK_SECRET.encode(),
|
|
166
|
+
payload,
|
|
167
|
+
"sha256",
|
|
168
|
+
).hex()
|
|
169
|
+
|
|
170
|
+
if not hmac.compare_digest(expected, sig_header.lower()):
|
|
171
|
+
raise ValueError("Webhook signature is invalid")
|
|
172
|
+
|
|
173
|
+
return json.loads(payload.decode())
|