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/hooks.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Claude Code hook handlers.
|
|
2
|
+
|
|
3
|
+
Hooks receive JSON on stdin, write response to stdout.
|
|
4
|
+
|
|
5
|
+
PreToolUse — fires before every tool call
|
|
6
|
+
→ check if MCP is loaded, warn if not
|
|
7
|
+
PostToolUse — fires after every tool call
|
|
8
|
+
→ log the call to usage.db (data moat)
|
|
9
|
+
|
|
10
|
+
Install with: mcpswitch setup
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from .usage import log_tool_call, _extract_server
|
|
21
|
+
from .profiles import get_active_profile, load_profiles
|
|
22
|
+
from .config import get_claude_code_config_path, get_all_mcp_servers
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _read_stdin() -> Optional[dict]:
|
|
26
|
+
try:
|
|
27
|
+
raw = sys.stdin.read()
|
|
28
|
+
return json.loads(raw) if raw.strip() else None
|
|
29
|
+
except Exception:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _write_response(data: dict) -> None:
|
|
34
|
+
sys.stdout.write(json.dumps(data) + "\n")
|
|
35
|
+
sys.stdout.flush()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def handle_post_tool_use() -> None:
|
|
39
|
+
"""PostToolUse hook — log every MCP tool call.
|
|
40
|
+
|
|
41
|
+
stdin JSON:
|
|
42
|
+
{
|
|
43
|
+
"session_id": "abc123",
|
|
44
|
+
"tool_name": "mcp__github__create_pull_request",
|
|
45
|
+
"tool_input": {...},
|
|
46
|
+
"tool_result": {...}
|
|
47
|
+
}
|
|
48
|
+
"""
|
|
49
|
+
data = _read_stdin()
|
|
50
|
+
if not data:
|
|
51
|
+
sys.exit(0)
|
|
52
|
+
|
|
53
|
+
tool_name = data.get("tool_name", "")
|
|
54
|
+
server = _extract_server(tool_name)
|
|
55
|
+
|
|
56
|
+
if not server:
|
|
57
|
+
sys.exit(0) # Not an MCP tool, skip
|
|
58
|
+
|
|
59
|
+
session_id = data.get("session_id")
|
|
60
|
+
project_dir = os.getcwd()
|
|
61
|
+
active_profile = get_active_profile()
|
|
62
|
+
|
|
63
|
+
# Determine success from tool result
|
|
64
|
+
result = data.get("tool_result", {})
|
|
65
|
+
success = True
|
|
66
|
+
if isinstance(result, dict):
|
|
67
|
+
success = not result.get("isError", False)
|
|
68
|
+
|
|
69
|
+
log_tool_call(
|
|
70
|
+
tool_name=tool_name,
|
|
71
|
+
session_id=session_id,
|
|
72
|
+
profile_name=active_profile,
|
|
73
|
+
project_dir=project_dir,
|
|
74
|
+
success=success,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
sys.exit(0)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def handle_pre_tool_use() -> None:
|
|
81
|
+
"""PreToolUse hook — check if MCP is loaded, warn user if not.
|
|
82
|
+
|
|
83
|
+
Does NOT block the tool call (exit 0 always).
|
|
84
|
+
Just warns the user so they know to switch profile next session.
|
|
85
|
+
|
|
86
|
+
stdin JSON:
|
|
87
|
+
{
|
|
88
|
+
"session_id": "abc123",
|
|
89
|
+
"tool_name": "mcp__github__create_pull_request",
|
|
90
|
+
"tool_input": {...}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
Output (if warning):
|
|
94
|
+
{
|
|
95
|
+
"hookSpecificOutput": {
|
|
96
|
+
"permissionDecision": "allow",
|
|
97
|
+
"userFacingMessage": "MCPSwitch: github not in active profile 'lean'. Run: mcpswitch use full"
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
"""
|
|
101
|
+
data = _read_stdin()
|
|
102
|
+
if not data:
|
|
103
|
+
sys.exit(0)
|
|
104
|
+
|
|
105
|
+
tool_name = data.get("tool_name", "")
|
|
106
|
+
server = _extract_server(tool_name)
|
|
107
|
+
|
|
108
|
+
if not server:
|
|
109
|
+
sys.exit(0)
|
|
110
|
+
|
|
111
|
+
# Check if this server is in the current Claude config
|
|
112
|
+
try:
|
|
113
|
+
config_path = get_claude_code_config_path()
|
|
114
|
+
loaded_servers = get_all_mcp_servers(config_path)
|
|
115
|
+
active_profile = get_active_profile()
|
|
116
|
+
|
|
117
|
+
if server not in loaded_servers:
|
|
118
|
+
# Find which profiles have this server
|
|
119
|
+
all_profiles = load_profiles()
|
|
120
|
+
profiles_with_server = [
|
|
121
|
+
name for name, servers in all_profiles.items()
|
|
122
|
+
if server in servers
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
if profiles_with_server:
|
|
126
|
+
suggestion = f"mcpswitch use {profiles_with_server[0]}"
|
|
127
|
+
else:
|
|
128
|
+
suggestion = f"mcpswitch add <profile> {server}"
|
|
129
|
+
|
|
130
|
+
msg = (
|
|
131
|
+
f"[MCPSwitch] '{server}' not in active profile '{active_profile or 'none'}'. "
|
|
132
|
+
f"Tool may fail. Fix: {suggestion}"
|
|
133
|
+
)
|
|
134
|
+
_write_response({
|
|
135
|
+
"hookSpecificOutput": {
|
|
136
|
+
"permissionDecision": "allow",
|
|
137
|
+
"userFacingMessage": msg,
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
sys.exit(0)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_hooks_config() -> dict:
|
|
147
|
+
"""Return the hooks config block to inject into ~/.claude/settings.json.
|
|
148
|
+
|
|
149
|
+
Uses the mcpswitch module directly so no path issues.
|
|
150
|
+
Python executable from current venv or system python.
|
|
151
|
+
"""
|
|
152
|
+
python = sys.executable
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
"PreToolUse": [
|
|
156
|
+
{
|
|
157
|
+
"matcher": "mcp__.*",
|
|
158
|
+
"hooks": [
|
|
159
|
+
{
|
|
160
|
+
"type": "command",
|
|
161
|
+
"command": f'"{python}" -m mcpswitch.hooks pre_tool_use',
|
|
162
|
+
}
|
|
163
|
+
],
|
|
164
|
+
}
|
|
165
|
+
],
|
|
166
|
+
"PostToolUse": [
|
|
167
|
+
{
|
|
168
|
+
"matcher": "mcp__.*",
|
|
169
|
+
"hooks": [
|
|
170
|
+
{
|
|
171
|
+
"type": "command",
|
|
172
|
+
"command": f'"{python}" -m mcpswitch.hooks post_tool_use',
|
|
173
|
+
}
|
|
174
|
+
],
|
|
175
|
+
}
|
|
176
|
+
],
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def install_hooks(dry_run: bool = False) -> dict:
|
|
181
|
+
"""Install MCPSwitch hooks into ~/.claude/settings.json.
|
|
182
|
+
|
|
183
|
+
Returns {"added": [...], "already_existed": [...], "settings_path": str}
|
|
184
|
+
"""
|
|
185
|
+
import json as _json
|
|
186
|
+
settings_path = Path.home() / ".claude" / "settings.json"
|
|
187
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
188
|
+
|
|
189
|
+
if settings_path.exists():
|
|
190
|
+
with open(settings_path, "r", encoding="utf-8") as f:
|
|
191
|
+
settings = _json.load(f)
|
|
192
|
+
else:
|
|
193
|
+
settings = {}
|
|
194
|
+
|
|
195
|
+
existing_hooks = settings.get("hooks", {})
|
|
196
|
+
new_hooks = get_hooks_config()
|
|
197
|
+
|
|
198
|
+
added = []
|
|
199
|
+
already_existed = []
|
|
200
|
+
|
|
201
|
+
for hook_type, hook_list in new_hooks.items():
|
|
202
|
+
existing = existing_hooks.get(hook_type, [])
|
|
203
|
+
# Check if mcpswitch hook already present
|
|
204
|
+
already = any("mcpswitch" in str(h) for h in existing)
|
|
205
|
+
if already:
|
|
206
|
+
already_existed.append(hook_type)
|
|
207
|
+
else:
|
|
208
|
+
if not dry_run:
|
|
209
|
+
existing_hooks[hook_type] = existing + hook_list
|
|
210
|
+
added.append(hook_type)
|
|
211
|
+
|
|
212
|
+
if not dry_run and added:
|
|
213
|
+
settings["hooks"] = existing_hooks
|
|
214
|
+
# Backup first
|
|
215
|
+
if settings_path.exists():
|
|
216
|
+
backup = settings_path.with_suffix(".json.bak")
|
|
217
|
+
import shutil
|
|
218
|
+
shutil.copy2(settings_path, backup)
|
|
219
|
+
with open(settings_path, "w", encoding="utf-8") as f:
|
|
220
|
+
_json.dump(settings, f, indent=2)
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
"added": added,
|
|
224
|
+
"already_existed": already_existed,
|
|
225
|
+
"settings_path": str(settings_path),
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def uninstall_hooks() -> dict:
|
|
230
|
+
"""Remove MCPSwitch hooks from ~/.claude/settings.json."""
|
|
231
|
+
import json as _json
|
|
232
|
+
settings_path = Path.home() / ".claude" / "settings.json"
|
|
233
|
+
|
|
234
|
+
if not settings_path.exists():
|
|
235
|
+
return {"removed": [], "settings_path": str(settings_path)}
|
|
236
|
+
|
|
237
|
+
with open(settings_path, "r", encoding="utf-8") as f:
|
|
238
|
+
settings = _json.load(f)
|
|
239
|
+
|
|
240
|
+
hooks = settings.get("hooks", {})
|
|
241
|
+
removed = []
|
|
242
|
+
|
|
243
|
+
for hook_type in list(hooks.keys()):
|
|
244
|
+
original_count = len(hooks[hook_type])
|
|
245
|
+
hooks[hook_type] = [
|
|
246
|
+
h for h in hooks[hook_type]
|
|
247
|
+
if "mcpswitch" not in str(h)
|
|
248
|
+
]
|
|
249
|
+
if len(hooks[hook_type]) < original_count:
|
|
250
|
+
removed.append(hook_type)
|
|
251
|
+
if not hooks[hook_type]:
|
|
252
|
+
del hooks[hook_type]
|
|
253
|
+
|
|
254
|
+
settings["hooks"] = hooks
|
|
255
|
+
with open(settings_path, "w", encoding="utf-8") as f:
|
|
256
|
+
_json.dump(settings, f, indent=2)
|
|
257
|
+
|
|
258
|
+
return {"removed": removed, "settings_path": str(settings_path)}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# Entry point when run as: python -m mcpswitch.hooks <command>
|
|
262
|
+
if __name__ == "__main__":
|
|
263
|
+
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
|
|
264
|
+
if cmd == "pre_tool_use":
|
|
265
|
+
handle_pre_tool_use()
|
|
266
|
+
elif cmd == "post_tool_use":
|
|
267
|
+
handle_post_tool_use()
|
|
268
|
+
else:
|
|
269
|
+
sys.exit(0)
|
mcpswitch/profiles.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Profile management — create, read, update, delete MCP profiles."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
PROFILES_DIR = Path.home() / ".mcpswitch"
|
|
8
|
+
PROFILES_FILE = PROFILES_DIR / "profiles.json"
|
|
9
|
+
STATE_FILE = PROFILES_DIR / "state.json"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _ensure_dir():
|
|
13
|
+
PROFILES_DIR.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_profiles() -> dict:
|
|
17
|
+
"""Load all profiles from disk."""
|
|
18
|
+
_ensure_dir()
|
|
19
|
+
if not PROFILES_FILE.exists():
|
|
20
|
+
return {}
|
|
21
|
+
with open(PROFILES_FILE, "r", encoding="utf-8") as f:
|
|
22
|
+
return json.load(f)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def save_profiles(profiles: dict) -> None:
|
|
26
|
+
"""Save all profiles to disk."""
|
|
27
|
+
_ensure_dir()
|
|
28
|
+
with open(PROFILES_FILE, "w", encoding="utf-8") as f:
|
|
29
|
+
json.dump(profiles, f, indent=2)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_active_profile() -> Optional[str]:
|
|
33
|
+
"""Return name of currently active profile."""
|
|
34
|
+
_ensure_dir()
|
|
35
|
+
if not STATE_FILE.exists():
|
|
36
|
+
return None
|
|
37
|
+
with open(STATE_FILE, "r", encoding="utf-8") as f:
|
|
38
|
+
return json.load(f).get("active")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def set_active_profile(name: Optional[str]) -> None:
|
|
42
|
+
"""Set the active profile name."""
|
|
43
|
+
_ensure_dir()
|
|
44
|
+
with open(STATE_FILE, "w", encoding="utf-8") as f:
|
|
45
|
+
json.dump({"active": name}, f, indent=2)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def create_profile(name: str, servers: dict) -> None:
|
|
49
|
+
"""Create or overwrite a profile with the given servers."""
|
|
50
|
+
profiles = load_profiles()
|
|
51
|
+
profiles[name] = servers
|
|
52
|
+
save_profiles(profiles)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_profile(name: str) -> Optional[dict]:
|
|
56
|
+
"""Get servers dict for a profile."""
|
|
57
|
+
return load_profiles().get(name)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def delete_profile(name: str) -> bool:
|
|
61
|
+
"""Delete a profile. Returns False if not found."""
|
|
62
|
+
profiles = load_profiles()
|
|
63
|
+
if name not in profiles:
|
|
64
|
+
return False
|
|
65
|
+
del profiles[name]
|
|
66
|
+
save_profiles(profiles)
|
|
67
|
+
if get_active_profile() == name:
|
|
68
|
+
set_active_profile(None)
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def list_profiles() -> list[str]:
|
|
73
|
+
"""Return sorted list of profile names."""
|
|
74
|
+
return sorted(load_profiles().keys())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def add_server_to_profile(profile_name: str, server_name: str, server_config: dict) -> bool:
|
|
78
|
+
"""Add a server to an existing profile. Returns False if profile not found."""
|
|
79
|
+
profiles = load_profiles()
|
|
80
|
+
if profile_name not in profiles:
|
|
81
|
+
return False
|
|
82
|
+
profiles[profile_name][server_name] = server_config
|
|
83
|
+
save_profiles(profiles)
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def remove_server_from_profile(profile_name: str, server_name: str) -> bool:
|
|
88
|
+
"""Remove a server from a profile. Returns False if not found."""
|
|
89
|
+
profiles = load_profiles()
|
|
90
|
+
if profile_name not in profiles:
|
|
91
|
+
return False
|
|
92
|
+
if server_name not in profiles[profile_name]:
|
|
93
|
+
return False
|
|
94
|
+
del profiles[profile_name][server_name]
|
|
95
|
+
save_profiles(profiles)
|
|
96
|
+
return True
|
mcpswitch/sync.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Multi-machine profile sync via GitHub Gists.
|
|
2
|
+
|
|
3
|
+
No backend server needed. Each user gets a private Gist as their sync target.
|
|
4
|
+
|
|
5
|
+
Pro feature:
|
|
6
|
+
mcpswitch sync --setup <github-token> → create private Gist, store token
|
|
7
|
+
mcpswitch sync --push → upload local profiles to Gist
|
|
8
|
+
mcpswitch sync --pull → download profiles from Gist
|
|
9
|
+
mcpswitch sync --status → show sync state
|
|
10
|
+
|
|
11
|
+
GitHub token needs: gist scope only (minimal permissions).
|
|
12
|
+
Create at: https://github.com/settings/tokens/new?scopes=gist
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import time
|
|
17
|
+
import urllib.request
|
|
18
|
+
import urllib.error
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
SYNC_CONFIG_FILE = Path.home() / ".mcpswitch" / "sync.json"
|
|
23
|
+
GIST_API = "https://api.github.com/gists"
|
|
24
|
+
GIST_FILENAME = "mcpswitch-profiles.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _load_sync_config() -> dict:
|
|
28
|
+
if not SYNC_CONFIG_FILE.exists():
|
|
29
|
+
return {}
|
|
30
|
+
try:
|
|
31
|
+
with open(SYNC_CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
32
|
+
return json.load(f)
|
|
33
|
+
except Exception:
|
|
34
|
+
return {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _save_sync_config(data: dict) -> None:
|
|
38
|
+
SYNC_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
with open(SYNC_CONFIG_FILE, "w", encoding="utf-8") as f:
|
|
40
|
+
json.dump(data, f, indent=2)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _github_request(
|
|
44
|
+
url: str,
|
|
45
|
+
method: str = "GET",
|
|
46
|
+
token: str = "",
|
|
47
|
+
payload: Optional[dict] = None,
|
|
48
|
+
) -> Optional[dict]:
|
|
49
|
+
try:
|
|
50
|
+
data = json.dumps(payload).encode("utf-8") if payload else None
|
|
51
|
+
req = urllib.request.Request(
|
|
52
|
+
url,
|
|
53
|
+
data=data,
|
|
54
|
+
method=method,
|
|
55
|
+
headers={
|
|
56
|
+
"Authorization": f"token {token}",
|
|
57
|
+
"Accept": "application/vnd.github.v3+json",
|
|
58
|
+
"Content-Type": "application/json",
|
|
59
|
+
"User-Agent": "mcpswitch/0.1",
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
63
|
+
return json.loads(resp.read())
|
|
64
|
+
except urllib.error.HTTPError as e:
|
|
65
|
+
body = e.read().decode("utf-8", errors="ignore")
|
|
66
|
+
return {"error": e.code, "message": body}
|
|
67
|
+
except Exception as e:
|
|
68
|
+
return {"error": str(e)}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def setup_sync(github_token: str) -> dict:
|
|
72
|
+
"""Create a private Gist and store the token for future syncs."""
|
|
73
|
+
from .profiles import load_profiles
|
|
74
|
+
|
|
75
|
+
profiles = load_profiles()
|
|
76
|
+
payload = {
|
|
77
|
+
"description": "MCPSwitch profile sync",
|
|
78
|
+
"public": False,
|
|
79
|
+
"files": {
|
|
80
|
+
GIST_FILENAME: {
|
|
81
|
+
"content": json.dumps({
|
|
82
|
+
"version": 1,
|
|
83
|
+
"profiles": profiles,
|
|
84
|
+
"synced_at": time.time(),
|
|
85
|
+
"machine": _get_machine_id(),
|
|
86
|
+
}, indent=2)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
result = _github_request(GIST_API, method="POST", token=github_token, payload=payload)
|
|
92
|
+
|
|
93
|
+
if not result or "error" in result:
|
|
94
|
+
return {"success": False, "message": f"Failed to create Gist: {result}"}
|
|
95
|
+
|
|
96
|
+
gist_id = result.get("id")
|
|
97
|
+
gist_url = result.get("html_url")
|
|
98
|
+
|
|
99
|
+
_save_sync_config({
|
|
100
|
+
"github_token": github_token,
|
|
101
|
+
"gist_id": gist_id,
|
|
102
|
+
"gist_url": gist_url,
|
|
103
|
+
"last_push": time.time(),
|
|
104
|
+
"last_pull": None,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
"success": True,
|
|
109
|
+
"gist_id": gist_id,
|
|
110
|
+
"gist_url": gist_url,
|
|
111
|
+
"message": f"Sync configured. Gist: {gist_url}",
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def push_profiles() -> dict:
|
|
116
|
+
"""Upload local profiles to the sync Gist."""
|
|
117
|
+
config = _load_sync_config()
|
|
118
|
+
if not config.get("gist_id"):
|
|
119
|
+
return {"success": False, "message": "Sync not configured. Run: mcpswitch sync --setup"}
|
|
120
|
+
|
|
121
|
+
from .profiles import load_profiles
|
|
122
|
+
profiles = load_profiles()
|
|
123
|
+
|
|
124
|
+
payload = {
|
|
125
|
+
"files": {
|
|
126
|
+
GIST_FILENAME: {
|
|
127
|
+
"content": json.dumps({
|
|
128
|
+
"version": 1,
|
|
129
|
+
"profiles": profiles,
|
|
130
|
+
"synced_at": time.time(),
|
|
131
|
+
"machine": _get_machine_id(),
|
|
132
|
+
}, indent=2)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
url = f"{GIST_API}/{config['gist_id']}"
|
|
138
|
+
result = _github_request(url, method="PATCH", token=config["github_token"], payload=payload)
|
|
139
|
+
|
|
140
|
+
if not result or "error" in result:
|
|
141
|
+
return {"success": False, "message": f"Push failed: {result}"}
|
|
142
|
+
|
|
143
|
+
config["last_push"] = time.time()
|
|
144
|
+
_save_sync_config(config)
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
"success": True,
|
|
148
|
+
"profiles_pushed": len(profiles),
|
|
149
|
+
"message": f"Pushed {len(profiles)} profile(s) to Gist.",
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def pull_profiles(merge: bool = True) -> dict:
|
|
154
|
+
"""Download profiles from the sync Gist.
|
|
155
|
+
|
|
156
|
+
If merge=True: remote profiles are merged with local (remote wins on conflict).
|
|
157
|
+
If merge=False: remote profiles completely replace local.
|
|
158
|
+
"""
|
|
159
|
+
config = _load_sync_config()
|
|
160
|
+
if not config.get("gist_id"):
|
|
161
|
+
return {"success": False, "message": "Sync not configured. Run: mcpswitch sync --setup"}
|
|
162
|
+
|
|
163
|
+
url = f"{GIST_API}/{config['gist_id']}"
|
|
164
|
+
result = _github_request(url, token=config["github_token"])
|
|
165
|
+
|
|
166
|
+
if not result or "error" in result:
|
|
167
|
+
return {"success": False, "message": f"Pull failed: {result}"}
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
content = result["files"][GIST_FILENAME]["content"]
|
|
171
|
+
remote_data = json.loads(content)
|
|
172
|
+
remote_profiles = remote_data.get("profiles", {})
|
|
173
|
+
except (KeyError, json.JSONDecodeError) as e:
|
|
174
|
+
return {"success": False, "message": f"Invalid Gist format: {e}"}
|
|
175
|
+
|
|
176
|
+
from .profiles import load_profiles, save_profiles
|
|
177
|
+
local_profiles = load_profiles()
|
|
178
|
+
|
|
179
|
+
if merge:
|
|
180
|
+
merged = {**local_profiles, **remote_profiles}
|
|
181
|
+
else:
|
|
182
|
+
merged = remote_profiles
|
|
183
|
+
|
|
184
|
+
save_profiles(merged)
|
|
185
|
+
|
|
186
|
+
config["last_pull"] = time.time()
|
|
187
|
+
_save_sync_config(config)
|
|
188
|
+
|
|
189
|
+
new_count = len(set(remote_profiles) - set(local_profiles))
|
|
190
|
+
updated_count = len(set(remote_profiles) & set(local_profiles))
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
"success": True,
|
|
194
|
+
"new_profiles": new_count,
|
|
195
|
+
"updated_profiles": updated_count,
|
|
196
|
+
"total_profiles": len(merged),
|
|
197
|
+
"message": f"Pulled: {new_count} new, {updated_count} updated. Total: {len(merged)} profiles.",
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def get_sync_status() -> dict:
|
|
202
|
+
"""Return current sync configuration and status."""
|
|
203
|
+
config = _load_sync_config()
|
|
204
|
+
if not config.get("gist_id"):
|
|
205
|
+
return {"configured": False}
|
|
206
|
+
|
|
207
|
+
last_push = config.get("last_push")
|
|
208
|
+
last_pull = config.get("last_pull")
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
"configured": True,
|
|
212
|
+
"gist_url": config.get("gist_url"),
|
|
213
|
+
"gist_id": config.get("gist_id"),
|
|
214
|
+
"last_push": last_push,
|
|
215
|
+
"last_push_ago": _ago(last_push),
|
|
216
|
+
"last_pull": last_pull,
|
|
217
|
+
"last_pull_ago": _ago(last_pull),
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _ago(ts: Optional[float]) -> str:
|
|
222
|
+
if not ts:
|
|
223
|
+
return "never"
|
|
224
|
+
diff = time.time() - ts
|
|
225
|
+
if diff < 60:
|
|
226
|
+
return f"{int(diff)}s ago"
|
|
227
|
+
if diff < 3600:
|
|
228
|
+
return f"{int(diff/60)}m ago"
|
|
229
|
+
if diff < 86400:
|
|
230
|
+
return f"{int(diff/3600)}h ago"
|
|
231
|
+
return f"{int(diff/86400)}d ago"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _get_machine_id() -> str:
|
|
235
|
+
import socket
|
|
236
|
+
import platform
|
|
237
|
+
return f"{socket.gethostname()}-{platform.system()}"
|