netconverter-cli 0.4.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.
@@ -0,0 +1,465 @@
1
+ """Wire an AI assistant to the local bridge (`netconverter mcp`) for one folder.
2
+
3
+ Every change is shown first and applied only after an explicit yes. Reading a
4
+ client's config to see whether it is already connected never modifies it. An
5
+ edited JSON file keeps a ``.bak`` copy of what it replaced; Claude Code's MCP
6
+ entry is changed only through its own ``claude mcp add`` command.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import difflib
12
+ import json
13
+ import os
14
+ import re
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ import tempfile
19
+ import tomllib
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Callable
23
+
24
+ SERVER_NAME = "netconverter"
25
+
26
+
27
+ def executable() -> str:
28
+ found = shutil.which("netconverter")
29
+ if found:
30
+ return str(Path(found).resolve())
31
+ script = Path(sys.argv[0])
32
+ return str(script.resolve()) if script.name == "netconverter" and script.exists() else "netconverter"
33
+
34
+
35
+ def _args(root: Path) -> list[str]:
36
+ return ["mcp", "--workspace", str(root)]
37
+
38
+
39
+ def _is_ours(entry) -> bool:
40
+ if not isinstance(entry, dict):
41
+ return False
42
+ command = str(entry.get("command", ""))
43
+ args = entry.get("args") or []
44
+ return Path(command).name == "netconverter" and bool(args) and args[0] == "mcp"
45
+
46
+
47
+ def _read_json(path: Path):
48
+ try:
49
+ data = json.loads(path.read_text(encoding="utf-8"))
50
+ except FileNotFoundError:
51
+ return {}
52
+ except (OSError, ValueError):
53
+ return None
54
+ return data if isinstance(data, dict) else None
55
+
56
+
57
+ def _write_json(path: Path, data: dict) -> None:
58
+ path.parent.mkdir(parents=True, exist_ok=True)
59
+ if path.exists():
60
+ shutil.copy2(path, path.with_name(path.name + ".bak"))
61
+ fd, name = tempfile.mkstemp(prefix=".nc-", dir=path.parent)
62
+ try:
63
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
64
+ json.dump(data, stream, indent=2)
65
+ stream.write("\n")
66
+ os.replace(name, path)
67
+ finally:
68
+ Path(name).unlink(missing_ok=True)
69
+
70
+
71
+ @dataclass
72
+ class Change:
73
+ key: str
74
+ title: str
75
+ where: str
76
+ summary: str
77
+ preview: str
78
+ can_apply: bool = True
79
+ manual: str = ""
80
+ apply: Callable[[], str] = field(default=lambda: "")
81
+ # Optional extra changes offered after this one applies, each with its own prompt.
82
+ followups: list = field(default_factory=list)
83
+ default_yes: bool = False
84
+
85
+
86
+ def _diff(path: Path, before, after: dict) -> str:
87
+ """A unified diff of a JSON file's change (before=None for a new file)."""
88
+ old = [] if before is None else (json.dumps(before, indent=2) + "\n").splitlines(keepends=True)
89
+ new = (json.dumps(after, indent=2) + "\n").splitlines(keepends=True)
90
+ label = str(path)
91
+ text = "".join(difflib.unified_diff(old, new, "/dev/null" if before is None else label, label))
92
+ return text.rstrip("\n") or "(no change)"
93
+
94
+
95
+ @dataclass
96
+ class ClientSpec:
97
+ title: str
98
+ configured: Callable[[], bool]
99
+ # "local" clients start the bridge themselves; "remote" ones (ChatGPT, claude.ai)
100
+ # can only reach an HTTPS MCP server, so connect prints their setup steps.
101
+ kind: str = "local"
102
+ installed: Callable[[], bool] = lambda: True
103
+
104
+
105
+ def _claude_configured() -> bool:
106
+ data = _read_json(Path.home() / ".claude.json") or {}
107
+ scopes = [data.get("mcpServers") or {}]
108
+ scopes += [(p or {}).get("mcpServers") or {} for p in (data.get("projects") or {}).values()
109
+ if isinstance(p, dict)]
110
+ return any(_is_ours(entry) for scope in scopes if isinstance(scope, dict) for entry in scope.values())
111
+
112
+
113
+ def _cursor_path() -> Path:
114
+ return Path.home() / ".cursor" / "mcp.json"
115
+
116
+
117
+ def _cursor_configured() -> bool:
118
+ data = _read_json(_cursor_path()) or {}
119
+ return any(_is_ours(e) for e in (data.get("mcpServers") or {}).values())
120
+
121
+
122
+ def _codex_path() -> Path:
123
+ return Path.home() / ".codex" / "config.toml"
124
+
125
+
126
+ def _codex_servers() -> dict:
127
+ try:
128
+ return tomllib.loads(_codex_path().read_text(encoding="utf-8")).get("mcp_servers") or {}
129
+ except (OSError, tomllib.TOMLDecodeError):
130
+ return {}
131
+
132
+
133
+ def _codex_configured() -> bool:
134
+ return any(_is_ours(e) for e in _codex_servers().values())
135
+
136
+
137
+ def _platform() -> str:
138
+ return sys.platform
139
+
140
+
141
+ def _desktop_path() -> Path | None:
142
+ """Claude Desktop's config file; None where Claude Desktop does not run."""
143
+ platform = _platform()
144
+ if platform == "darwin":
145
+ return Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
146
+ if platform == "win32":
147
+ appdata = os.getenv("APPDATA")
148
+ base = Path(appdata) if appdata else Path.home() / "AppData" / "Roaming"
149
+ return base / "Claude" / "claude_desktop_config.json"
150
+ return None
151
+
152
+
153
+ def _desktop_configured() -> bool:
154
+ path = _desktop_path()
155
+ data = (_read_json(path) or {}) if path else {}
156
+ return any(_is_ours(e) for e in (data.get("mcpServers") or {}).values())
157
+
158
+
159
+ def _claude_settings_path() -> Path:
160
+ return Path.home() / ".claude" / "settings.json"
161
+
162
+
163
+ def read_deny_rule(root: Path) -> str:
164
+ """Claude Code permission rule denying direct reads under ``root``.
165
+
166
+ In Claude Code rules ``//`` marks an absolute path (``/x`` alone is relative
167
+ to the settings file); a Windows drive ``C:\\x`` is written ``//c/x``.
168
+ """
169
+ posix = Path(root).as_posix()
170
+ drive = re.match(r"([A-Za-z]):/?(.*)", posix)
171
+ path = f"//{drive.group(1).lower()}/{drive.group(2)}" if drive else "/" + posix
172
+ return f"Read({path.rstrip('/')}/**)"
173
+
174
+
175
+ def _too_broad_for_deny(root: Path) -> bool:
176
+ """A deny rule on the home folder (or anything containing it, or a filesystem root)
177
+ would stop Claude Code reading every project; it is only offered for a dedicated folder."""
178
+ root = Path(root).resolve()
179
+ home = Path.home().resolve()
180
+ return root == home or home.is_relative_to(root) or root.parent == root
181
+
182
+
183
+ def plan_claude_read_deny(root: Path) -> Change:
184
+ path = _claude_settings_path()
185
+ rule = read_deny_rule(root)
186
+ title, where = "Claude Code read rule", str(path)
187
+ if _too_broad_for_deny(root):
188
+ return Change("claude-code-deny", title, where,
189
+ f"Skipped: {root} is your home folder (or contains it), and a read rule there "
190
+ "would stop Claude Code reading every project. Use a dedicated folder for "
191
+ "configurations, e.g. `netconverter --workspace ~/migrations connect claude-code`.",
192
+ rule, can_apply=False, manual="Nothing was changed.")
193
+ data = _read_json(path)
194
+ permissions = data.get("permissions", {}) if isinstance(data, dict) else None
195
+ deny = permissions.get("deny", []) if isinstance(permissions, dict) else None
196
+ if not isinstance(deny, list):
197
+ block = json.dumps({"permissions": {"deny": [rule]}}, indent=2)
198
+ return Change("claude-code-deny", title, where,
199
+ f"{path} could not be merged safely; add the deny rule by hand.",
200
+ block, can_apply=False, manual=f"Add {rule} to permissions.deny in {path}.")
201
+ if rule in deny:
202
+ return Change("claude-code-deny", title, where, f"{path} already denies {rule}.", rule,
203
+ can_apply=False, manual="Nothing to change.")
204
+ after = json.loads(json.dumps(data))
205
+ after.setdefault("permissions", {}).setdefault("deny", []).append(rule)
206
+
207
+ def write() -> str:
208
+ current = _read_json(path) # re-read: never drop a change made since the preview
209
+ if not isinstance(current, dict):
210
+ raise RuntimeError(f"{path} is no longer valid JSON; nothing was changed")
211
+ rules = current.setdefault("permissions", {}).setdefault("deny", [])
212
+ if not isinstance(rules, list):
213
+ raise RuntimeError(f"{path} permissions.deny is not a list; nothing was changed")
214
+ if rule not in rules:
215
+ rules.append(rule)
216
+ _write_json(path, current)
217
+ return (f"Claude Code will not read files in this folder directly ({rule}); "
218
+ "it works on them through the bridge. Restart Claude Code to apply.")
219
+ return Change("claude-code-deny", title, where,
220
+ "Stop Claude Code reading this folder's files directly. The bridge uploads "
221
+ "configurations by path, so the assistant never needs their text.",
222
+ _diff(path, data if path.exists() else None, after), apply=write, default_yes=True)
223
+
224
+
225
+ def _json_servers_configured(path: Path | None) -> bool:
226
+ data = (_read_json(path) or {}) if path else {}
227
+ servers = data.get("mcpServers") or {}
228
+ return isinstance(servers, dict) and any(_is_ours(e) for e in servers.values())
229
+
230
+
231
+ def _gemini_path() -> Path:
232
+ return Path.home() / ".gemini" / "settings.json"
233
+
234
+
235
+ def _windsurf_path() -> Path:
236
+ return Path.home() / ".codeium" / "windsurf" / "mcp_config.json"
237
+
238
+
239
+ def _lmstudio_path() -> Path:
240
+ return Path.home() / ".lmstudio" / "mcp.json"
241
+
242
+
243
+ def _exists(path_of: Callable[[], Path]) -> Callable[[], bool]:
244
+ """Installed when the client's config folder exists (resolved per call, never at import)."""
245
+ return lambda: path_of().parent.exists()
246
+
247
+
248
+ def _plan_json_servers(key: str, title: str, path: Path, entry: dict, done: str) -> Change:
249
+ """Merge mcpServers.netconverter into a client's JSON config, keeping every other key."""
250
+ block = json.dumps({"mcpServers": {SERVER_NAME: entry}}, indent=2)
251
+ data = _read_json(path)
252
+ if data is None or not isinstance(data.get("mcpServers", {}), dict):
253
+ return Change(key, title, str(path),
254
+ f"{title}'s config is not valid JSON (or mcpServers is not an object); edit it by hand.",
255
+ block, can_apply=False, manual=f"Add the block above to {path}.")
256
+ after = json.loads(json.dumps(data))
257
+ after.setdefault("mcpServers", {})[SERVER_NAME] = entry
258
+
259
+ def write() -> str:
260
+ current = _read_json(path) # re-read: never drop a change made since the preview
261
+ if current is None or not isinstance(current.get("mcpServers", {}), dict):
262
+ raise RuntimeError(f"{path} is no longer valid JSON; nothing was changed")
263
+ current.setdefault("mcpServers", {})[SERVER_NAME] = entry
264
+ _write_json(path, current)
265
+ return f"Updated {path}. {done}"
266
+ return Change(key, title, str(path),
267
+ f"Sets mcpServers.{SERVER_NAME} in {path}, keeping everything else (backup: .bak).",
268
+ _diff(path, data if path.exists() else None, after), apply=write)
269
+
270
+
271
+ def remote_url() -> str:
272
+ from .remote_mcp import DEFAULT_MCP_URL
273
+ return os.getenv("NETCONVERTER_MCP_URL", DEFAULT_MCP_URL).rstrip("/")
274
+
275
+
276
+ REMOTE_STEPS = {
277
+ "chatgpt": (
278
+ "ChatGPT",
279
+ "In ChatGPT: Settings → Apps & Connectors → Advanced settings → turn on Developer mode, "
280
+ "then Create (custom connector).\n"
281
+ " Name: NetConverter\n"
282
+ " MCP server URL: {url}\n"
283
+ " Authentication: OAuth. Under advanced OAuth settings enter the NetConverter OAuth\n"
284
+ " client ID your administrator gives you, then sign in with your\n"
285
+ " NetConverter account when asked.\n"
286
+ "Then enable NetConverter in a chat from the + menu (Developer mode)."),
287
+ "claude-ai": (
288
+ "claude.ai (web and mobile)",
289
+ "On claude.ai: Settings → Connectors → Add custom connector.\n"
290
+ " Name: NetConverter\n"
291
+ " URL: {url}\n"
292
+ " Advanced settings → OAuth Client ID: the NetConverter OAuth client ID your\n"
293
+ " administrator gives you.\n"
294
+ "Click Connect and sign in with your NetConverter account."),
295
+ }
296
+
297
+
298
+ def plan_remote(key: str) -> Change:
299
+ title, steps = REMOTE_STEPS[key]
300
+ url = remote_url()
301
+ return Change(key, title, title,
302
+ f"{title} can only use remote MCP servers, so it connects to NetConverter's hosted "
303
+ "MCP rather than this folder's bridge. You get your jobs, results, account and "
304
+ "knowledge base; to submit a configuration, use a local client "
305
+ "(Claude Desktop, Claude Code, Cursor, Gemini CLI …) with this bridge. "
306
+ "Your NetConverter administrator must have MCP access enabled for your account.",
307
+ steps.format(url=url), can_apply=False,
308
+ manual="Follow the steps above in the app; nothing on this computer needs to change.")
309
+
310
+
311
+ def _vscode_configured(root: Path | None = None) -> bool:
312
+ data = _read_json((root or Path.cwd()) / ".vscode" / "mcp.json") or {}
313
+ return any(_is_ours(e) for e in (data.get("servers") or {}).values())
314
+
315
+
316
+ CLIENTS = {
317
+ "claude-code": ClientSpec("Claude Code", _claude_configured,
318
+ installed=lambda: bool(shutil.which("claude")) or (Path.home() / ".claude.json").exists()),
319
+ "claude-desktop": ClientSpec("Claude Desktop", _desktop_configured,
320
+ installed=lambda: bool(_desktop_path()) and _desktop_path().parent.exists()),
321
+ "cursor": ClientSpec("Cursor", _cursor_configured, installed=_exists(_cursor_path)),
322
+ "vscode": ClientSpec("VS Code", _vscode_configured,
323
+ installed=lambda: bool(shutil.which("code"))),
324
+ "codex": ClientSpec("Codex CLI", _codex_configured, installed=_exists(_codex_path)),
325
+ "gemini": ClientSpec("Gemini CLI", lambda: _json_servers_configured(_gemini_path()),
326
+ installed=lambda: bool(shutil.which("gemini")) or _gemini_path().parent.exists()),
327
+ "windsurf": ClientSpec("Windsurf", lambda: _json_servers_configured(_windsurf_path()),
328
+ installed=_exists(_windsurf_path)),
329
+ "lmstudio": ClientSpec("LM Studio", lambda: _json_servers_configured(_lmstudio_path()),
330
+ installed=lambda: (Path.home() / ".lmstudio").exists()),
331
+ "chatgpt": ClientSpec("ChatGPT", lambda: False, kind="remote"),
332
+ "claude-ai": ClientSpec("claude.ai (web and mobile)", lambda: False, kind="remote"),
333
+ }
334
+
335
+
336
+ def installed_local_clients() -> list[str]:
337
+ """Local clients that look installed on this computer (for connect --all)."""
338
+ out = []
339
+ for key, spec in CLIENTS.items():
340
+ if spec.kind != "local" or key == "vscode": # VS Code is per folder, not per user
341
+ continue
342
+ try:
343
+ if spec.installed():
344
+ out.append(key)
345
+ except Exception:
346
+ continue
347
+ return out
348
+
349
+
350
+ def plan(key: str, root: Path) -> Change:
351
+ root = Path(root).resolve()
352
+ exe = executable()
353
+ if key == "claude-code":
354
+ argv = ["claude", "mcp", "add", "--scope", "user", SERVER_NAME, "--", exe, *_args(root)]
355
+ found = shutil.which("claude")
356
+
357
+ def run() -> str:
358
+ done = subprocess.run([found, *argv[1:]], capture_output=True, text=True, timeout=60)
359
+ if done.returncode != 0:
360
+ raise RuntimeError((done.stderr or done.stdout or "claude mcp add failed").strip()[:300])
361
+ return "Claude Code: restart it or run /mcp to see the netconverter tools."
362
+ return Change(key, "Claude Code", "Claude Code (user scope)",
363
+ f"Adds the '{SERVER_NAME}' MCP server for this folder.",
364
+ " ".join(argv), can_apply=bool(found),
365
+ manual="Claude Code is not on PATH. Run the command above in a terminal where it is.",
366
+ apply=run, followups=[plan_claude_read_deny(root)])
367
+ if key == "claude-desktop":
368
+ path = _desktop_path()
369
+ entry = {"command": exe, "args": _args(root)}
370
+ block = json.dumps({"mcpServers": {SERVER_NAME: entry}}, indent=2)
371
+ if path is None:
372
+ return Change(key, "Claude Desktop", "Claude Desktop", "Claude Desktop runs on macOS and Windows only.",
373
+ block, can_apply=False, manual="Add the block above to claude_desktop_config.json.")
374
+ data = _read_json(path)
375
+ if data is None or not isinstance(data.get("mcpServers", {}), dict):
376
+ return Change(key, "Claude Desktop", str(path),
377
+ "Claude Desktop's config is not valid JSON (or mcpServers is not an object); edit it by hand.",
378
+ block, can_apply=False, manual=f"Add the block above to {path}.")
379
+ after = json.loads(json.dumps(data))
380
+ after.setdefault("mcpServers", {})[SERVER_NAME] = entry
381
+
382
+ def write() -> str:
383
+ current = _read_json(path)
384
+ if current is None or not isinstance(current.get("mcpServers", {}), dict):
385
+ raise RuntimeError(f"{path} is no longer valid JSON; nothing was changed")
386
+ current.setdefault("mcpServers", {})[SERVER_NAME] = entry
387
+ _write_json(path, current)
388
+ return f"Updated {path}. Quit and reopen Claude Desktop to load the netconverter tools."
389
+ return Change(key, "Claude Desktop", str(path),
390
+ f"Sets mcpServers.{SERVER_NAME} in {path}, keeping everything else (backup: .bak).",
391
+ _diff(path, data if path.exists() else None, after), apply=write)
392
+ if key == "gemini":
393
+ return _plan_json_servers(key, "Gemini CLI", _gemini_path(), {"command": exe, "args": _args(root)},
394
+ "Restart gemini, then run /mcp to see the netconverter tools.")
395
+ if key == "windsurf":
396
+ return _plan_json_servers(key, "Windsurf", _windsurf_path(), {"command": exe, "args": _args(root)},
397
+ "Refresh MCP servers in Windsurf (Cascade → MCP) or restart it.")
398
+ if key == "lmstudio":
399
+ return _plan_json_servers(key, "LM Studio", _lmstudio_path(), {"command": exe, "args": _args(root)},
400
+ "Restart LM Studio; use a tool-capable model to call the netconverter tools.")
401
+ if key in REMOTE_STEPS:
402
+ return plan_remote(key)
403
+ if key == "cursor":
404
+ path = _cursor_path()
405
+ data = _read_json(path)
406
+ entry = {"command": exe, "args": _args(root)}
407
+ preview = json.dumps({"mcpServers": {SERVER_NAME: entry}}, indent=2)
408
+ if data is None:
409
+ return Change(key, "Cursor", str(path), "Cursor's mcp.json is not valid JSON; edit it by hand.",
410
+ preview, can_apply=False, manual=f"Add the block above to {path}.")
411
+
412
+ def write() -> str:
413
+ servers = data.setdefault("mcpServers", {})
414
+ servers[SERVER_NAME] = entry
415
+ _write_json(path, data)
416
+ return f"Updated {path}. Cursor picks it up after a reload."
417
+ return Change(key, "Cursor", str(path), f"Sets mcpServers.{SERVER_NAME} in {path}.", preview, apply=write)
418
+ if key == "vscode":
419
+ path = root / ".vscode" / "mcp.json"
420
+ data = _read_json(path)
421
+ entry = {"type": "stdio", "command": exe, "args": _args(root)}
422
+ preview = json.dumps({"servers": {SERVER_NAME: entry}}, indent=2)
423
+ if data is None:
424
+ return Change(key, "VS Code", str(path), "This folder's .vscode/mcp.json is not valid JSON.",
425
+ preview, can_apply=False, manual=f"Add the block above to {path}.")
426
+
427
+ def write() -> str:
428
+ data.setdefault("servers", {})[SERVER_NAME] = entry
429
+ _write_json(path, data)
430
+ return f"Updated {path}. Open this folder in VS Code and start the server from MCP: List Servers."
431
+ return Change(key, "VS Code", str(path), f"Sets servers.{SERVER_NAME} in this folder's .vscode/mcp.json.",
432
+ preview, apply=write)
433
+ if key == "codex":
434
+ path = _codex_path()
435
+ block = (f"\n[mcp_servers.{SERVER_NAME}]\ncommand = {json.dumps(exe)}\n"
436
+ f"args = {json.dumps(_args(root))}\n")
437
+ if SERVER_NAME in _codex_servers():
438
+ return Change(key, "Codex CLI", str(path), f"{path} already has [mcp_servers.{SERVER_NAME}].",
439
+ block.strip(), can_apply=False,
440
+ manual=f"Edit that table in {path} if it should point at this folder.")
441
+
442
+ def append() -> str:
443
+ path.parent.mkdir(parents=True, exist_ok=True)
444
+ if path.exists():
445
+ shutil.copy2(path, path.with_name(path.name + ".bak"))
446
+ with path.open("a", encoding="utf-8") as stream:
447
+ stream.write(block)
448
+ return f"Appended [mcp_servers.{SERVER_NAME}] to {path}. Restart Codex."
449
+ return Change(key, "Codex CLI", str(path), f"Appends [mcp_servers.{SERVER_NAME}] to {path}.",
450
+ block.strip(), apply=append)
451
+ raise ValueError(f"Unknown AI client {key!r}; choose one of {', '.join(CLIENTS)}")
452
+
453
+
454
+ def configured_clients(root: Path | None = None) -> list[str]:
455
+ out = []
456
+ for key, spec in CLIENTS.items():
457
+ if spec.kind != "local":
458
+ continue
459
+ try:
460
+ ok = _vscode_configured(root) if key == "vscode" else spec.configured()
461
+ except Exception:
462
+ ok = False
463
+ if ok:
464
+ out.append(spec.title)
465
+ return out