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.
- netconverter_cli/__init__.py +3 -0
- netconverter_cli/activity.py +83 -0
- netconverter_cli/api.py +257 -0
- netconverter_cli/backend.py +107 -0
- netconverter_cli/branding.py +79 -0
- netconverter_cli/catalog.py +162 -0
- netconverter_cli/clerk.py +602 -0
- netconverter_cli/cli.py +521 -0
- netconverter_cli/commands.py +63 -0
- netconverter_cli/connect.py +465 -0
- netconverter_cli/controller.py +331 -0
- netconverter_cli/credentials.py +94 -0
- netconverter_cli/guided.py +365 -0
- netconverter_cli/mcp_server.py +640 -0
- netconverter_cli/palette.py +16 -0
- netconverter_cli/presentation.py +254 -0
- netconverter_cli/privacy.py +105 -0
- netconverter_cli/providers.py +246 -0
- netconverter_cli/remote_mcp.py +175 -0
- netconverter_cli/setup.py +179 -0
- netconverter_cli/tui.py +607 -0
- netconverter_cli/update.py +344 -0
- netconverter_cli/workspace.py +148 -0
- netconverter_cli-0.4.0.dist-info/METADATA +167 -0
- netconverter_cli-0.4.0.dist-info/RECORD +28 -0
- netconverter_cli-0.4.0.dist-info/WHEEL +4 -0
- netconverter_cli-0.4.0.dist-info/entry_points.txt +2 -0
- netconverter_cli-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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.
|