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,344 @@
1
+ """`netconverter update` and the once-a-day "new version available" notice.
2
+
3
+ Only the public GitHub Releases API of this repository is contacted, without
4
+ credentials: no NetConverter key or sign-in is ever sent there. A copy that uv
5
+ installed from PyPI is upgraded with ``uv tool upgrade``; any other copy gets the
6
+ release wheel, installed only after its SHA-256 matches ``SHA256SUMS``. The
7
+ passive check runs in a background thread with a short timeout, writes a small
8
+ cache file, swallows every error and never prints to stdout.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+ import threading
22
+ import time
23
+ import tomllib
24
+ from importlib import metadata
25
+ from pathlib import Path
26
+ from urllib.parse import urlsplit
27
+
28
+ import httpx
29
+
30
+ from . import __version__
31
+
32
+ REPO = "valeronlabs/netconverter_terminals"
33
+ DIST = "netconverter-cli"
34
+ LATEST_URL = f"https://api.github.com/repos/{REPO}/releases/latest"
35
+ DOWNLOAD_PREFIX = f"https://github.com/{REPO}/releases/download/"
36
+ # GitHub answers an asset download with a redirect to its object storage.
37
+ DOWNLOAD_HOSTS = frozenset({"github.com", "objects.githubusercontent.com",
38
+ "release-assets.githubusercontent.com"})
39
+ CHECK_INTERVAL_S = 24 * 3600
40
+ NOTICE_TIMEOUT_S = 3.0
41
+ MAX_WHEEL_BYTES = 50 * 1024 * 1024
42
+ MAX_SUMS_BYTES = 64 * 1024
43
+ MAX_API_BYTES = 1024 * 1024
44
+ _VERSION = re.compile(r"v?(\d{1,4})\.(\d{1,4})\.(\d{1,4})\Z")
45
+ _WHEEL = re.compile(r"netconverter_cli-(\d{1,4}\.\d{1,4}\.\d{1,4})-py3-none-any\.whl\Z")
46
+ _HEX = re.compile(r"[0-9a-f]{64}\Z")
47
+
48
+
49
+ class UpdateError(RuntimeError):
50
+ pass
51
+
52
+
53
+ def disabled() -> bool:
54
+ return os.getenv("NETCONVERTER_NO_UPDATE_CHECK") == "1"
55
+
56
+
57
+ def parse_version(text) -> tuple | None:
58
+ """(major, minor, patch) for a plain release version; None for anything else (pre-releases)."""
59
+ match = _VERSION.fullmatch(str(text or "").strip())
60
+ return tuple(int(part) for part in match.groups()) if match else None
61
+
62
+
63
+ def newer(latest, installed=__version__) -> bool:
64
+ a, b = parse_version(latest), parse_version(installed)
65
+ return bool(a and b and a > b)
66
+
67
+
68
+ def cache_dir() -> Path:
69
+ """The per-user cache folder (never the workspace)."""
70
+ if sys.platform == "darwin":
71
+ return Path.home() / "Library" / "Caches" / "netconverter"
72
+ if os.name == "nt":
73
+ base = os.getenv("LOCALAPPDATA")
74
+ return (Path(base) if base else Path.home() / "AppData" / "Local") / "netconverter" / "Cache"
75
+ base = os.getenv("XDG_CACHE_HOME")
76
+ return (Path(base) if base else Path.home() / ".cache") / "netconverter"
77
+
78
+
79
+ def _cache_file() -> Path:
80
+ return cache_dir() / "update-check.json"
81
+
82
+
83
+ def read_cache() -> dict:
84
+ try:
85
+ data = json.loads(_cache_file().read_text(encoding="utf-8"))
86
+ except (OSError, ValueError):
87
+ return {}
88
+ return data if isinstance(data, dict) else {}
89
+
90
+
91
+ def write_cache(**fields) -> None:
92
+ path = _cache_file()
93
+ data = {**read_cache(), **fields}
94
+ path.parent.mkdir(parents=True, exist_ok=True)
95
+ fd, name = tempfile.mkstemp(prefix=".update-", dir=path.parent)
96
+ try:
97
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
98
+ json.dump(data, stream)
99
+ os.replace(name, path)
100
+ finally:
101
+ Path(name).unlink(missing_ok=True)
102
+
103
+
104
+ # -- GitHub ---------------------------------------------------------------
105
+
106
+
107
+ def _http(transport=None, timeout=10.0) -> httpx.Client:
108
+ return httpx.Client(timeout=timeout, transport=transport, follow_redirects=False, trust_env=False,
109
+ headers={"Accept": "application/vnd.github+json",
110
+ "User-Agent": f"netconverter-cli/{__version__}"})
111
+
112
+
113
+ def latest_release(*, transport=None, timeout=10.0) -> dict:
114
+ """{version, tag, wheel_name, wheel_url, sums_url}; sums_url/wheel_* may be None."""
115
+ with _http(transport, timeout) as http:
116
+ try:
117
+ response = http.get(LATEST_URL)
118
+ except httpx.HTTPError:
119
+ raise UpdateError("Could not reach GitHub to check for a new release") from None
120
+ if response.status_code == 404:
121
+ raise UpdateError("No published release was found")
122
+ if response.is_error or len(response.content) > MAX_API_BYTES:
123
+ raise UpdateError(f"GitHub answered HTTP {response.status_code}")
124
+ try:
125
+ body = response.json()
126
+ except ValueError:
127
+ raise UpdateError("GitHub returned an invalid response") from None
128
+ tag = body.get("tag_name") if isinstance(body, dict) else None
129
+ version = parse_version(tag)
130
+ if version is None:
131
+ raise UpdateError("The latest release has no plain version tag")
132
+ out = {"version": ".".join(map(str, version)), "tag": str(tag), "wheel_name": None,
133
+ "wheel_url": None, "sums_url": None}
134
+ for asset in body.get("assets") or []:
135
+ if not isinstance(asset, dict):
136
+ continue
137
+ name, url = asset.get("name"), asset.get("browser_download_url")
138
+ if not isinstance(name, str) or not isinstance(url, str) or url != f"{DOWNLOAD_PREFIX}{tag}/{name}":
139
+ continue
140
+ wheel = _WHEEL.fullmatch(name)
141
+ if wheel and wheel.group(1) == out["version"]:
142
+ out["wheel_name"], out["wheel_url"] = name, url
143
+ elif name == "SHA256SUMS":
144
+ out["sums_url"] = url
145
+ return out
146
+
147
+
148
+ def _download(http: httpx.Client, url: str, limit: int) -> bytes:
149
+ for _ in range(5): # bounded redirects, each to an allowlisted HTTPS host
150
+ parts = urlsplit(url)
151
+ if parts.scheme != "https" or (parts.hostname or "") not in DOWNLOAD_HOSTS:
152
+ raise UpdateError("Release download redirected to an unexpected host")
153
+ try:
154
+ with http.stream("GET", url, headers={"Accept": "application/octet-stream"}) as response:
155
+ if response.is_redirect:
156
+ url = str(response.url.join(response.headers.get("location", "")))
157
+ continue
158
+ if response.is_error:
159
+ raise UpdateError(f"Release download failed (HTTP {response.status_code})")
160
+ chunks, size = [], 0
161
+ for chunk in response.iter_bytes():
162
+ size += len(chunk)
163
+ if size > limit:
164
+ raise UpdateError("Release asset is larger than expected")
165
+ chunks.append(chunk)
166
+ return b"".join(chunks)
167
+ except httpx.HTTPError:
168
+ raise UpdateError("Release download failed") from None
169
+ raise UpdateError("Too many redirects downloading the release")
170
+
171
+
172
+ def expected_hash(sums: bytes, filename: str) -> str:
173
+ for line in sums.decode("utf-8", "replace").splitlines():
174
+ parts = line.strip().split()
175
+ if len(parts) == 2 and parts[1].lstrip("*") == filename and _HEX.fullmatch(parts[0].lower()):
176
+ return parts[0].lower()
177
+ raise UpdateError(f"SHA256SUMS has no entry for {filename}")
178
+
179
+
180
+ def fetch_verified_wheel(release: dict, dest: Path, *, transport=None) -> Path:
181
+ if not release.get("wheel_url") or not release.get("sums_url"):
182
+ raise UpdateError("The latest release has no wheel and SHA256SUMS to verify")
183
+ with _http(transport, 60.0) as http:
184
+ sums = _download(http, release["sums_url"], MAX_SUMS_BYTES)
185
+ wheel = _download(http, release["wheel_url"], MAX_WHEEL_BYTES)
186
+ want = expected_hash(sums, release["wheel_name"])
187
+ got = hashlib.sha256(wheel).hexdigest()
188
+ if got != want:
189
+ raise UpdateError(f"Checksum mismatch for {release['wheel_name']}; nothing was installed")
190
+ path = Path(dest) / release["wheel_name"]
191
+ path.write_bytes(wheel)
192
+ return path
193
+
194
+
195
+ # -- how this copy was installed ------------------------------------------
196
+
197
+ # Receipt keys that mean uv installed from a file, URL or checkout, not the index.
198
+ _DIRECT_KEYS = ("path", "url", "git", "directory", "editable")
199
+
200
+
201
+ def _has_direct_url() -> bool:
202
+ """PEP 610: a wheel, URL or checkout install records direct_url.json; an index install does not."""
203
+ try:
204
+ return metadata.distribution(DIST).read_text("direct_url.json") is not None
205
+ except metadata.PackageNotFoundError:
206
+ return True
207
+
208
+
209
+ def from_pypi(prefix=None, direct_url=_has_direct_url) -> bool:
210
+ """True when this copy is a `uv tool install netconverter-cli` from the package index."""
211
+ try:
212
+ receipt = tomllib.loads((Path(prefix or sys.prefix) / "uv-receipt.toml").read_text(encoding="utf-8"))
213
+ except (OSError, ValueError):
214
+ return False
215
+ for req in (receipt.get("tool") or {}).get("requirements") or []:
216
+ if isinstance(req, dict) and req.get("name") == DIST:
217
+ return not any(key in req for key in _DIRECT_KEYS) and not direct_url()
218
+ return False
219
+
220
+
221
+ # -- `netconverter update` ------------------------------------------------
222
+
223
+
224
+ def run(args, *, transport=None, runner=subprocess.run, which=shutil.which, ask=input,
225
+ pypi=from_pypi) -> int:
226
+ try:
227
+ release = latest_release(transport=transport)
228
+ except UpdateError as exc:
229
+ print(f"netconverter update: {exc}", file=sys.stderr)
230
+ return 1
231
+ available = newer(release["version"])
232
+ try:
233
+ write_cache(checked_at=time.time(), latest=release["version"])
234
+ except OSError:
235
+ pass
236
+ if args.check:
237
+ print(json.dumps({"installed": __version__, "latest": release["version"],
238
+ "update_available": available}))
239
+ return 0
240
+ if not available:
241
+ print(f"NetConverter CLI {__version__} is up to date (latest release {release['version']}).")
242
+ return 0
243
+ uv = which("uv")
244
+ if uv and pypi():
245
+ return _upgrade_from_pypi(args, release, uv, runner, ask)
246
+ if not uv:
247
+ print(f"NetConverter CLI {release['version']} is available (installed {__version__}), "
248
+ "but uv is not on PATH. Download the wheel and SHA256SUMS from\n"
249
+ f" https://github.com/{REPO}/releases/tag/{release['tag']}\n"
250
+ "verify it with `shasum -a 256 -c SHA256SUMS`, then run:\n"
251
+ f" uv tool install --force ./{release['wheel_name'] or 'netconverter_cli-<version>-py3-none-any.whl'}")
252
+ return 1
253
+ print(f"Update NetConverter CLI {__version__} → {release['version']} "
254
+ f"(verified wheel from github.com/{REPO}).")
255
+ if not args.yes and ask("Install it now? [y/N] ").strip().lower() not in {"y", "yes"}:
256
+ print("Nothing was changed.")
257
+ return 0
258
+ with tempfile.TemporaryDirectory(prefix="netconverter-update-") as tmp:
259
+ try:
260
+ wheel = fetch_verified_wheel(release, Path(tmp), transport=transport)
261
+ except UpdateError as exc:
262
+ print(f"netconverter update: {exc}", file=sys.stderr)
263
+ return 1
264
+ print(f"SHA-256 verified: {wheel.name}")
265
+ done = runner([uv, "tool", "install", "--force", str(wheel)])
266
+ if done.returncode != 0:
267
+ print("uv tool install failed; your current version is unchanged.", file=sys.stderr)
268
+ return 1
269
+ print(f"Installed NetConverter CLI {release['version']}.")
270
+ return 0
271
+
272
+
273
+ def _upgrade_from_pypi(args, release, uv, runner, ask) -> int:
274
+ print(f"Update NetConverter CLI {__version__} → {release['version']} (PyPI, `uv tool upgrade {DIST}`).")
275
+ if not args.yes and ask("Upgrade it now? [y/N] ").strip().lower() not in {"y", "yes"}:
276
+ print("Nothing was changed.")
277
+ return 0
278
+ if runner([uv, "tool", "upgrade", DIST]).returncode != 0:
279
+ print("uv tool upgrade failed; your current version is unchanged.", file=sys.stderr)
280
+ return 1
281
+ print("Upgraded NetConverter CLI from PyPI. Run `netconverter --version` to confirm.")
282
+ return 0
283
+
284
+
285
+ # -- passive check --------------------------------------------------------
286
+
287
+
288
+ def cached_status() -> dict:
289
+ """{latest, update_available} from the cache only; never touches the network."""
290
+ latest = read_cache().get("latest")
291
+ return {"latest": latest if parse_version(latest) else None,
292
+ "update_available": newer(latest) if parse_version(latest) else None}
293
+
294
+
295
+ def refresh(*, transport=None, timeout=NOTICE_TIMEOUT_S) -> None:
296
+ """One bounded check that updates the cache. Errors are swallowed."""
297
+ try:
298
+ release = latest_release(transport=transport, timeout=timeout)
299
+ write_cache(checked_at=time.time(), latest=release["version"])
300
+ except Exception:
301
+ try:
302
+ write_cache(checked_at=time.time()) # do not retry on every command while offline
303
+ except Exception:
304
+ pass
305
+
306
+
307
+ def _stale(now=None) -> bool:
308
+ checked = read_cache().get("checked_at")
309
+ return not isinstance(checked, (int, float)) or (now or time.time()) - checked >= CHECK_INTERVAL_S
310
+
311
+
312
+ def status(*, transport=None, timeout=NOTICE_TIMEOUT_S) -> dict:
313
+ """For the MCP `account` tool: the cache, refreshed (bounded) when older than a day."""
314
+ if disabled():
315
+ return {"latest": None, "update_available": None}
316
+ try:
317
+ if _stale():
318
+ refresh(transport=transport, timeout=timeout)
319
+ return cached_status()
320
+ except Exception:
321
+ return {"latest": None, "update_available": None}
322
+
323
+
324
+ def notify(stream=None, *, transport=None, background=True) -> None:
325
+ """At most once a day, a one-line notice on stderr. Never blocks or raises."""
326
+ try:
327
+ stream = stream or sys.stderr
328
+ if disabled() or not stream.isatty():
329
+ return
330
+ now = time.time()
331
+ state = cached_status()
332
+ notified = read_cache().get("notified_at")
333
+ recently = isinstance(notified, (int, float)) and now - notified < CHECK_INTERVAL_S
334
+ if state["update_available"] and not recently:
335
+ stream.write(f"NetConverter CLI {state['latest']} is available (installed {__version__}). "
336
+ "Run `netconverter update`. Set NETCONVERTER_NO_UPDATE_CHECK=1 to hide this.\n")
337
+ write_cache(notified_at=now)
338
+ if _stale(now):
339
+ if background:
340
+ threading.Thread(target=refresh, kwargs={"transport": transport}, daemon=True).start()
341
+ else:
342
+ refresh(transport=transport)
343
+ except Exception:
344
+ pass
@@ -0,0 +1,148 @@
1
+ """Bounded local IO. This module is never exposed as a model tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import tempfile
10
+ from pathlib import Path
11
+
12
+ MAX_CONFIG_BYTES = 50_000
13
+ _IDENTIFIER = re.compile(r"[A-Za-z0-9_-]{1,80}\Z")
14
+
15
+
16
+ class WorkspaceError(ValueError):
17
+ pass
18
+
19
+
20
+ class Workspace:
21
+ def __init__(self, root: Path | str):
22
+ self.root = Path(root).resolve(strict=True)
23
+ self.state_dir = self.root / ".netconverter"
24
+
25
+ def _inside(self, path: Path) -> Path:
26
+ resolved = path.resolve()
27
+ if not resolved.is_relative_to(self.root):
28
+ raise WorkspaceError("Path is outside the workspace")
29
+ return resolved
30
+
31
+ def locate(self, filename: str | os.PathLike) -> Path:
32
+ """Resolve a user/model-supplied path, confined to the workspace.
33
+
34
+ Relative paths are taken from the workspace root; absolute paths must
35
+ already point inside it. The named file itself may not be a symlink,
36
+ and any symlinked directory on the way must still resolve inside the
37
+ root. The client's own ``.netconverter`` state folder is never offered.
38
+ """
39
+ raw = Path(filename)
40
+ if "\x00" in str(filename):
41
+ raise WorkspaceError("Invalid path")
42
+ candidate = raw if raw.is_absolute() else self.root / raw
43
+ if candidate.is_symlink():
44
+ raise WorkspaceError("Symbolic links are not accepted; pass the real file")
45
+ resolved = self._inside(candidate)
46
+ if resolved == self.state_dir or resolved.is_relative_to(self.state_dir):
47
+ raise WorkspaceError("The client state folder is not a configuration source")
48
+ return resolved
49
+
50
+ def snapshot(
51
+ self, filename: str, max_bytes: int = MAX_CONFIG_BYTES
52
+ ) -> tuple[Path, bytes, str]:
53
+ path = self.locate(filename)
54
+ if not path.is_file():
55
+ raise WorkspaceError("Select a regular configuration file")
56
+ # Bound the read, including when the source changes during the read.
57
+ with path.open("rb") as stream:
58
+ content = stream.read(max_bytes + 1)
59
+ if not content or len(content) > max_bytes:
60
+ raise WorkspaceError(
61
+ "Free pilot inputs must be 1–50,000 bytes; contact NetConverter for larger files"
62
+ if max_bytes == MAX_CONFIG_BYTES
63
+ else f"Configuration must be 1–{max_bytes:,} bytes"
64
+ )
65
+ try:
66
+ content.decode("utf-8")
67
+ except UnicodeDecodeError:
68
+ raise WorkspaceError(
69
+ "Select a UTF-8 text, SET, or XML configuration"
70
+ ) from None
71
+ if b"\x00" in content:
72
+ raise WorkspaceError("Binary configurations are not supported")
73
+ return path, content, hashlib.sha256(content).hexdigest()
74
+
75
+ def load(self) -> dict:
76
+ path = self._inside(self.state_dir / "session.json")
77
+ if not path.exists():
78
+ return {"version": 1, "configurations": {}, "jobs": {}}
79
+ if path.stat().st_size > 1_000_000:
80
+ raise WorkspaceError("Session metadata exceeds its limit")
81
+ try:
82
+ state = json.loads(path.read_text())
83
+ if state.get("version") != 1 or not isinstance(state.get("jobs"), dict):
84
+ raise ValueError()
85
+ return state
86
+ except (ValueError, TypeError):
87
+ raise WorkspaceError(
88
+ "Invalid session metadata; originals have not been changed"
89
+ ) from None
90
+
91
+ def store(self, state: dict) -> None:
92
+ self._inside(self.state_dir).mkdir(mode=0o700, exist_ok=True)
93
+ self._atomic_replace(
94
+ self.state_dir / "session.json", json.dumps(state, indent=2).encode()
95
+ )
96
+
97
+ @staticmethod
98
+ def _atomic_replace(path: Path, content: bytes) -> None:
99
+ fd, name = tempfile.mkstemp(prefix=".nc-", dir=path.parent)
100
+ try:
101
+ with os.fdopen(fd, "wb") as stream:
102
+ stream.write(content)
103
+ stream.flush()
104
+ os.fsync(stream.fileno())
105
+ os.replace(name, path)
106
+ finally:
107
+ Path(name).unlink(missing_ok=True)
108
+
109
+ def save_artifact(
110
+ self, job_id: str, filename: str, content: bytes, sha256: str
111
+ ) -> Path:
112
+ if not _IDENTIFIER.fullmatch(job_id):
113
+ raise WorkspaceError("Invalid server job ID")
114
+ if (
115
+ not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,120}", filename)
116
+ or ".." in filename
117
+ ):
118
+ raise WorkspaceError("Invalid artifact filename")
119
+ if (
120
+ not re.fullmatch(r"[a-f0-9]{64}", sha256)
121
+ or hashlib.sha256(content).hexdigest() != sha256
122
+ ):
123
+ raise WorkspaceError("Artifact checksum failed; no file was written")
124
+ path = self.root / f"{job_id}.{filename}"
125
+ if path.is_symlink():
126
+ raise WorkspaceError("Artifact destination is a symlink")
127
+ if path.exists():
128
+ if path.read_bytes() == content:
129
+ return path
130
+ raise WorkspaceError(
131
+ "Artifact destination already exists with different contents"
132
+ )
133
+ fd, temporary = tempfile.mkstemp(prefix=".nc-download-", dir=self.root)
134
+ try:
135
+ with os.fdopen(fd, "wb") as stream:
136
+ stream.write(content)
137
+ stream.flush()
138
+ os.fsync(stream.fileno())
139
+ # Atomic no-clobber publication, including a concurrent user write.
140
+ try:
141
+ os.link(temporary, path)
142
+ except FileExistsError:
143
+ raise WorkspaceError(
144
+ "Artifact destination was created during download"
145
+ ) from None
146
+ finally:
147
+ Path(temporary).unlink(missing_ok=True)
148
+ return path
@@ -0,0 +1,167 @@
1
+ Metadata-Version: 2.5
2
+ Name: netconverter-cli
3
+ Version: 0.4.0
4
+ Summary: NetConverter CLI — any-vendor network configuration migration, validation and optimization, and a local MCP bridge for your AI assistant
5
+ Project-URL: Homepage, https://netconverter.ai
6
+ Project-URL: Repository, https://github.com/valeronlabs/netconverter_terminals
7
+ Project-URL: Documentation, https://github.com/valeronlabs/netconverter_terminals/blob/main/QUICK_START.md
8
+ Project-URL: Issues, https://github.com/valeronlabs/netconverter_terminals/issues
9
+ Project-URL: Changelog, https://github.com/valeronlabs/netconverter_terminals/blob/main/CHANGELOG.md
10
+ Author-email: ValeronLabs LLC <contact@netconverter.ai>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: check-point,cisco,firewall,fortinet,mcp,migration,model-context-protocol,netconverter,network-automation,palo-alto
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Environment :: Console
16
+ Classifier: Intended Audience :: System Administrators
17
+ Classifier: Intended Audience :: Telecommunications Industry
18
+ Classifier: Operating System :: MacOS
19
+ Classifier: Operating System :: Microsoft :: Windows
20
+ Classifier: Operating System :: POSIX :: Linux
21
+ Classifier: Programming Language :: Python :: 3
22
+ Classifier: Programming Language :: Python :: 3 :: Only
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Topic :: System :: Networking
26
+ Classifier: Topic :: System :: Networking :: Firewalls
27
+ Classifier: Topic :: Utilities
28
+ Requires-Python: >=3.12
29
+ Requires-Dist: httpx==0.28.1
30
+ Requires-Dist: keyring==25.7.0
31
+ Requires-Dist: pydantic==2.13.5
32
+ Requires-Dist: textual==8.2.8
33
+ Description-Content-Type: text/markdown
34
+
35
+ # NetConverter CLI
36
+
37
+ <!-- mcp-name: ai.netconverter/netconverter -->
38
+
39
+ **Migrate, validate and optimize network configurations from the folder that holds them — and let your own AI assistant do it too, without the configuration ever passing through the model.**
40
+
41
+ NetConverter — by ValeronLabs LLC.
42
+
43
+ ```text
44
+ ◆ NetConverter v0.3.0 ● Server ◆ Bridge claude-code · 2m ago
45
+ ▍ Start
46
+ ▍ /convert Migrate a configuration to another vendor
47
+ ▍ /validate Check a configuration before migrating
48
+ ▍ /optimize Cleanup and optimization findings
49
+ ▍ /activity What your AI assistant did here
50
+ ╭ What does edge-fw.conf contain? ───────────────────────────────╮
51
+ │ ▸ 1 Fortinet FortiGate · detected │
52
+ │ 2 Cisco ASA │
53
+ ╰──────────────────────────────── ↑↓ choose · 1–9 jump · Enter ───╯
54
+ ```
55
+
56
+ One small client, three ways in:
57
+
58
+ | | What it is |
59
+ | --- | --- |
60
+ | `netconverter` | A keyboard-first full-screen app: pick a file, answer a few inline questions, follow the job, get verified outputs beside your source. |
61
+ | `netconverter mcp` | A local MCP bridge for Claude Code, Claude Desktop, Cursor, VS Code and Codex. Your AI passes a **file path**; the bridge reads the file and uploads it straight to NetConverter. The AI sees paths, job IDs and summaries — never the configuration. |
62
+ | `netconverter validate edge.cfg --vendor fortinet` | Plain one-shot commands that print JSON, for scripts and CI. |
63
+
64
+ ## What it supports
65
+
66
+ Whatever the NetConverter server you sign in to supports — the client reads
67
+ `GET /capabilities` and builds every menu from it, so new vendors appear
68
+ without a client update. Today that is:
69
+
70
+ - **Firewalls:** Palo Alto (SET, XML, Panorama), Fortinet FortiGate, Cisco FMC (JSON);
71
+ Cisco ASA as a source. Check Point is a source through a handoff bundle in the portal.
72
+ - **Routers and switches:** Cisco IOS-XE, Juniper JunOS (braces and set), Aruba CX;
73
+ ArubaOS as a source.
74
+ - **Jobs:** convert, validate, optimize; follow-up questions on NAT, policy, routes,
75
+ interfaces, unused objects, object dependencies and traffic flows.
76
+
77
+ Run `netconverter capabilities` to see exactly what your credential can do.
78
+ A 0.1.x **Terminal Quick pilot key** still works and keeps its ASA → Palo Alto
79
+ scope; the app labels it as such.
80
+
81
+ ## Start here
82
+
83
+ One line installs everything (uv, the CLI from PyPI), signs you in and connects
84
+ your AI assistants to `~/migrations`:
85
+
86
+ ```sh
87
+ curl -LsSf https://netconverter.ai/install.sh | sh # macOS / Linux
88
+ ```
89
+
90
+ ```powershell
91
+ irm https://netconverter.ai/install.ps1 | iex # Windows PowerShell
92
+ ```
93
+
94
+ Options: `--yes` (no prompts, for IT/MDM), `--no-connect`, `--workspace PATH`
95
+ (`curl … | sh -s -- --yes`; PowerShell: `-Yes -NoConnect -Workspace PATH`, see
96
+ [scripts/install.ps1](scripts/install.ps1)). Running it again only does what is missing.
97
+
98
+ Or by hand:
99
+
100
+ ```sh
101
+ # 1. Install (Python 3.12+ via uv: https://docs.astral.sh/uv)
102
+ uv tool install --python 3.12 netconverter-cli
103
+
104
+ # 2. Sign in once
105
+ netconverter login --clerk # your NetConverter account, in the browser
106
+ # or: netconverter login # paste an API key at a hidden prompt
107
+
108
+ # 3. Work in the folder that holds your configs
109
+ cd ~/migrations && netconverter
110
+ ```
111
+
112
+ Upgrading from `netconverter-terminal` 0.1.x: `uv tool uninstall netconverter-terminal`
113
+ first. Saved keys and sign-ins carry over (Keychain service **NetConverter Terminal**).
114
+
115
+ Full walkthrough: [Quick start](QUICK_START.md).
116
+
117
+ ## Use it from your AI
118
+
119
+ Step-by-step guides for every assistant (Claude, ChatGPT, Cursor, Gemini CLI, Codex …):
120
+ [docs/connect](docs/connect/README.md).
121
+
122
+ ```sh
123
+ netconverter connect claude-code # or claude-desktop, cursor, vscode, codex, gemini, windsurf, lmstudio
124
+ netconverter connect --all # every assistant found on this computer
125
+ netconverter connect chatgpt # ChatGPT / claude.ai: remote-connector setup steps
126
+ ```
127
+
128
+ `connect` shows the exact change it will make and asks before applying it
129
+ (Claude Code through its own `claude mcp add`; JSON files keep a `.bak`). For
130
+ Claude Code it then offers a `Read` deny rule for the folder, so the assistant
131
+ works on your configurations only through the bridge.
132
+ Then ask your assistant, for example: *"Validate edge-fw.conf and tell me what it finds,"*
133
+ *"Convert it to Palo Alto XML,"* *"Can WEB reach DB on tcp/1433?"*
134
+
135
+ Bridge tools: `submit_file`, `job_status`, `wait_for_job`, `account`, `download`,
136
+ plus an allowlist of NetConverter's read-only tools (reports, evidence, NAT, flows,
137
+ knowledge base). `netconverter mcp --strict` narrows those to the strict-view set.
138
+
139
+ Inside the app, `/activity` and the header's **Bridge** pill show what your AI did
140
+ through the bridge — paths, sizes, SHA-256 and job IDs only.
141
+
142
+ ## Stay current
143
+
144
+ `netconverter update` upgrades a PyPI install with `uv tool upgrade netconverter-cli`;
145
+ a copy installed from a release wheel gets the latest wheel after its SHA-256 is
146
+ checked (`--check` only reports). Interactive commands mention a new release at most once
147
+ a day; set `NETCONVERTER_NO_UPDATE_CHECK=1` to turn that off.
148
+
149
+ ## Boundaries
150
+
151
+ - Configurations travel only from your workspace to NetConverter, over HTTPS.
152
+ The bridge never returns configuration text to the AI; the server tokenizes
153
+ addresses and removes credentials from every summary.
154
+ - The client never pushes to a device. Outputs are files you review and load.
155
+ - Downloads are hash-verified and never overwrite a file you changed.
156
+ - An optional in-app model (`/model`: Anthropic, OpenAI, Gemini or local Ollama)
157
+ only chooses among fixed operations; it never receives configurations or results.
158
+
159
+ ## Documentation
160
+
161
+ - [Quick start](QUICK_START.md) · [Commands and troubleshooting](docs/COMMANDS.md)
162
+ - [Privacy and data flow](docs/PRIVACY.md) · [Verification status](docs/VERIFICATION.md)
163
+ - [Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md)
164
+
165
+ The client is [MIT licensed](LICENSE). The license does not grant access to the
166
+ hosted service or license the server code. macOS is the exercised platform;
167
+ Windows acceptance remains pending.