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,3 @@
1
+ """NetConverter CLI: file transfer and conversation are separate paths."""
2
+
3
+ __version__ = "0.4.0"
@@ -0,0 +1,83 @@
1
+ """What the local MCP bridge did, as metadata the full-screen app can show.
2
+
3
+ One JSON line per bridge tool call in ``.netconverter/bridge.jsonl``: time,
4
+ AI client name, tool, workspace-relative path, sha256, byte count, job id,
5
+ job type, vendors and status. Configuration text, tool results and
6
+ credentials are never written: only the fields in ``FIELDS`` survive, and
7
+ each must be a short scalar.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import re
15
+ import time
16
+ from pathlib import Path
17
+
18
+ LOG_NAME = "bridge.jsonl"
19
+ FIELDS = ("ts", "client", "tool", "path", "sha256", "bytes", "files", "job_id",
20
+ "job_type", "source_vendor", "target_vendor", "status", "error")
21
+ MAX_BYTES = 512 * 1024
22
+ KEEP_LINES = 500
23
+ _TEXT = re.compile(r"[\w .:/@+()-]{0,200}\Z")
24
+
25
+
26
+ def _clean(entry: dict) -> dict:
27
+ out = {}
28
+ for key in FIELDS:
29
+ value = entry.get(key)
30
+ if isinstance(value, bool) or value is None:
31
+ continue
32
+ if isinstance(value, (int, float)) and abs(value) < 1e13:
33
+ out[key] = value
34
+ elif isinstance(value, str) and _TEXT.fullmatch(value):
35
+ out[key] = value
36
+ return out
37
+
38
+
39
+ def record(state_dir: Path, **entry) -> None:
40
+ """Append one metadata line; never raises (the bridge must keep serving)."""
41
+ try:
42
+ state_dir.mkdir(mode=0o700, exist_ok=True)
43
+ path = state_dir / LOG_NAME
44
+ if path.is_symlink():
45
+ return
46
+ line = json.dumps(_clean({"ts": round(time.time(), 3), **entry}), sort_keys=True) + "\n"
47
+ fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
48
+ with os.fdopen(fd, "a", encoding="utf-8") as stream:
49
+ stream.write(line)
50
+ if path.stat().st_size > MAX_BYTES:
51
+ kept = path.read_text(encoding="utf-8").splitlines()[-KEEP_LINES:]
52
+ tmp = path.with_suffix(".tmp")
53
+ tmp.write_text("\n".join(kept) + "\n", encoding="utf-8")
54
+ os.replace(tmp, path)
55
+ except OSError:
56
+ pass
57
+
58
+
59
+ def recent(state_dir: Path, limit: int = 20) -> list[dict]:
60
+ """Newest-last entries; malformed lines are skipped."""
61
+ path = state_dir / LOG_NAME
62
+ try:
63
+ if path.is_symlink() or not path.is_file():
64
+ return []
65
+ lines = path.read_text(encoding="utf-8").splitlines()[-max(limit, 1):]
66
+ except OSError:
67
+ return []
68
+ out = []
69
+ for line in lines:
70
+ try:
71
+ item = json.loads(line)
72
+ except ValueError:
73
+ continue
74
+ if isinstance(item, dict):
75
+ out.append(_clean(item))
76
+ return out
77
+
78
+
79
+ def mtime(state_dir: Path) -> float:
80
+ try:
81
+ return (state_dir / LOG_NAME).stat().st_mtime
82
+ except OSError:
83
+ return 0.0
@@ -0,0 +1,257 @@
1
+ """Direct NetConverter data channel. No model SDK imports belong here."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import uuid
7
+ from urllib.parse import urlsplit
8
+
9
+ import httpx
10
+
11
+ DEFAULT_BASE = "https://api.netconverter.ai/external/v1"
12
+
13
+
14
+ class APIError(RuntimeError):
15
+ def __init__(self, status: int, code: str, request_id: str = "", detail=None):
16
+ super().__init__(
17
+ f"NetConverter {code} (HTTP {status}, request {request_id or 'unavailable'})"
18
+ )
19
+ self.status, self.code, self.request_id, self.detail = (
20
+ status,
21
+ code,
22
+ request_id,
23
+ detail,
24
+ )
25
+
26
+
27
+ def check_url(base: str) -> str:
28
+ """HTTPS only (HTTP for explicit loopback testing); no userinfo/query/fragment."""
29
+ parts = urlsplit(base)
30
+ if parts.username or parts.password or parts.query or parts.fragment:
31
+ raise ValueError("Invalid server URL")
32
+ if parts.scheme != "https" and not (
33
+ parts.scheme == "http" and parts.hostname in {"127.0.0.1", "localhost", "::1"}
34
+ ):
35
+ raise ValueError(
36
+ "Server connections require HTTPS (HTTP only for explicit loopback testing)"
37
+ )
38
+ return base
39
+
40
+
41
+ class StaticCredential:
42
+ """An API key (``nck_*`` or terminal_quick). It cannot be refreshed."""
43
+
44
+ kind = "key"
45
+
46
+ def __init__(self, key: str):
47
+ self._key = key
48
+
49
+ def token(self) -> str:
50
+ return self._key
51
+
52
+ def refresh(self) -> bool:
53
+ return False
54
+
55
+
56
+ class BearerAuth(httpx.Auth):
57
+ """Bearer from a credential; one refresh-and-retry on HTTP 401.
58
+
59
+ Works for static keys (no retry) and for Clerk OAuth sessions, whose
60
+ ``token()`` refreshes an expiring access token and whose ``refresh()``
61
+ forces a refresh after the server rejected the current one.
62
+ """
63
+
64
+ requires_request_body = True
65
+
66
+ def __init__(self, credential):
67
+ self.credential = credential
68
+
69
+ def auth_flow(self, request):
70
+ request.headers["Authorization"] = f"Bearer {self.credential.token()}"
71
+ response = yield request
72
+ if response.status_code == 401 and self.credential.refresh():
73
+ request.headers["Authorization"] = f"Bearer {self.credential.token()}"
74
+ yield request
75
+
76
+
77
+ def as_credential(key):
78
+ return StaticCredential(key) if isinstance(key, str) else key
79
+
80
+
81
+ class Client:
82
+ # Path allowlist for this client's restricted surface.
83
+ _ALLOWED = re.compile(r"quick/[A-Za-z0-9_/-]{1,200}\Z")
84
+
85
+ def __init__(self, key, base: str = DEFAULT_BASE, *, transport=None):
86
+ check_url(base)
87
+ self.credential = as_credential(key)
88
+ self.http = httpx.Client(
89
+ base_url=base.rstrip("/") + "/",
90
+ timeout=60,
91
+ auth=BearerAuth(self.credential),
92
+ transport=transport,
93
+ follow_redirects=False,
94
+ trust_env=False,
95
+ )
96
+
97
+ def close(self):
98
+ self.http.close()
99
+
100
+ @staticmethod
101
+ def identifier(value: str) -> str:
102
+ if not re.fullmatch(r"[A-Za-z0-9_-]{1,80}", value):
103
+ raise ValueError("Invalid resource identifier")
104
+ return value
105
+
106
+ def _send(self, method, path, idempotency_key=None, **kwargs):
107
+ if not self._ALLOWED.fullmatch(path) or ".." in path:
108
+ raise ValueError("Only the restricted NetConverter API may be called")
109
+ headers = {"X-Request-ID": "req_" + uuid.uuid4().hex}
110
+ if idempotency_key:
111
+ headers["Idempotency-Key"] = idempotency_key
112
+ try:
113
+ response = self.http.request(method, path, headers=headers, **kwargs)
114
+ except httpx.HTTPError:
115
+ raise APIError(0, "connection_failed", headers["X-Request-ID"]) from None
116
+ return response, headers["X-Request-ID"]
117
+
118
+ @staticmethod
119
+ def _error(response, request_id):
120
+ try:
121
+ body = response.json()
122
+ except ValueError:
123
+ return APIError(response.status_code, "invalid_response", request_id)
124
+ error = body.get("error", {}) if isinstance(body, dict) else {}
125
+ if not isinstance(error, dict):
126
+ error = {}
127
+ return APIError(
128
+ response.status_code,
129
+ str(error.get("code", "request_failed")),
130
+ body.get("request_id", request_id) if isinstance(body, dict) else request_id,
131
+ body.get("detail", error.get("detail")) if isinstance(body, dict) else None,
132
+ )
133
+
134
+ def request(
135
+ self, method: str, path: str, *, idempotency_key: str | None = None, **kwargs
136
+ ):
137
+ response, request_id = self._send(method, path, idempotency_key, **kwargs)
138
+ try:
139
+ body = response.json()
140
+ except ValueError:
141
+ raise APIError(response.status_code, "invalid_response", request_id) from None
142
+ if response.is_error or not isinstance(body, dict) or body.get("success") is not True:
143
+ raise self._error(response, request_id)
144
+ return body["data"]
145
+
146
+ def capabilities(self):
147
+ return self.request("GET", "quick/capabilities")
148
+
149
+ def upload(self, content: bytes, vendor: str):
150
+ return self.request(
151
+ "POST", "quick/configurations", content=content, params={"vendor": vendor}
152
+ )
153
+
154
+ def submit(
155
+ self, configuration_id: str, operation: str, options: dict, idempotency_key: str
156
+ ):
157
+ return self.request(
158
+ "POST",
159
+ "quick/jobs",
160
+ idempotency_key=idempotency_key,
161
+ json={
162
+ "configuration_id": configuration_id,
163
+ "operation": operation,
164
+ "options": options,
165
+ },
166
+ )
167
+
168
+ def jobs(self):
169
+ return self.request("GET", "quick/jobs")
170
+
171
+ def status(self, job_id: str):
172
+ return self.request("GET", f"quick/jobs/{self.identifier(job_id)}")
173
+
174
+ def query(
175
+ self,
176
+ job_id: str,
177
+ operation: str,
178
+ arguments: dict,
179
+ idempotency_key: str,
180
+ offset: int = 0,
181
+ ):
182
+ return self.request(
183
+ "POST",
184
+ "quick/queries",
185
+ idempotency_key=idempotency_key,
186
+ json={
187
+ "job_id": job_id,
188
+ "operation": operation,
189
+ "arguments": arguments,
190
+ "offset": offset,
191
+ },
192
+ )
193
+
194
+ def target(self, job_id: str):
195
+ return self.request("POST", f"quick/jobs/{self.identifier(job_id)}/target")
196
+
197
+ def artifacts(self, job_id: str):
198
+ return self.request("GET", f"quick/jobs/{self.identifier(job_id)}/artifacts")
199
+
200
+ def download(self, job_id: str, artifact_id: str):
201
+ return self.request(
202
+ "GET",
203
+ f"quick/jobs/{self.identifier(job_id)}/artifacts/{self.identifier(artifact_id)}",
204
+ )
205
+
206
+
207
+ class ExternalClient(Client):
208
+ """Full External API v1 (``/jobs``, ``/me``, ``/capabilities``), same Bearer handling.
209
+
210
+ Accepts ``nck_*`` keys and Clerk OAuth access tokens.
211
+ """
212
+
213
+ _ALLOWED = re.compile(
214
+ r"(?:me|capabilities|jobs|jobs/[A-Za-z0-9_-]{1,80}(?:/translated|/report|/evidence\.xlsx)?)\Z"
215
+ )
216
+
217
+ def me(self):
218
+ return self.request("GET", "me")
219
+
220
+ def capabilities(self):
221
+ """Vendors, source→target paths, job types and limits POST /jobs accepts."""
222
+ return self.request("GET", "capabilities")
223
+
224
+ def jobs(self, limit: int = 25):
225
+ data = self.request("GET", "jobs", params={"comparable": "false", "limit": limit})
226
+ return data.get("jobs", []) if isinstance(data, dict) else []
227
+
228
+ def evidence_workbook(self, job_id: str) -> bytes | None:
229
+ """The customer evidence workbook; None when this job has none."""
230
+ response, request_id = self._send(
231
+ "GET", f"jobs/{self.identifier(job_id)}/evidence.xlsx"
232
+ )
233
+ if response.status_code in (404, 409, 422):
234
+ return None
235
+ if response.is_error:
236
+ raise self._error(response, request_id)
237
+ return response.content
238
+
239
+ def submit_job(self, body: dict, idempotency_key: str):
240
+ return self.request("POST", "jobs", idempotency_key=idempotency_key, json=body)
241
+
242
+ def job(self, job_id: str):
243
+ return self.request("GET", f"jobs/{self.identifier(job_id)}")
244
+
245
+ def report(self, job_id: str):
246
+ return self.request("GET", f"jobs/{self.identifier(job_id)}/report")
247
+
248
+ def translated(self, job_id: str) -> bytes | None:
249
+ """Translated configuration bytes; None while absent (queued/blocked)."""
250
+ response, request_id = self._send(
251
+ "GET", f"jobs/{self.identifier(job_id)}/translated"
252
+ )
253
+ if response.status_code == 404:
254
+ return None
255
+ if response.is_error:
256
+ raise self._error(response, request_id)
257
+ return response.content
@@ -0,0 +1,107 @@
1
+ """Which NetConverter API a credential reaches, and the full-API job path.
2
+
3
+ Shared by the full-screen app and the local MCP bridge so the two can never
4
+ disagree about what a credential may do. ``external``: an ``nck_*`` key or a
5
+ Clerk sign-in on the full External API (``/jobs``, any supported vendor).
6
+ ``quick``: a 0.1.x Terminal Quick pilot key, whose scopes never reach
7
+ ``/jobs`` (the server answers 403), limited to ASA → Palo Alto.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+
15
+ from .api import APIError, Client, ExternalClient
16
+ from .catalog import JOB_TYPES, device_type_of
17
+
18
+ # Full External API inline-submit ceiling (server: 5 MB).
19
+ EXTERNAL_MAX_BYTES = 5 * 1024 * 1024
20
+ QUICK_JOB_TYPES = frozenset({"convert", "optimize"})
21
+
22
+
23
+ def detect_profile(credential, server, *, transport=None) -> tuple[str, frozenset]:
24
+ """('external', job types /me advertises) or ('quick', pilot job types).
25
+
26
+ Raises APIError when neither surface accepts the credential.
27
+ """
28
+ client = ExternalClient(credential, server, transport=transport)
29
+ try:
30
+ try:
31
+ me = client.me()
32
+ except APIError as exc:
33
+ if exc.status != 403 or getattr(credential, "kind", "key") != "key":
34
+ raise
35
+ quick = Client(credential, server, transport=transport)
36
+ try:
37
+ quick.capabilities()
38
+ finally:
39
+ quick.close()
40
+ return "quick", QUICK_JOB_TYPES
41
+ advertised = me.get("job_types") if isinstance(me, dict) else None
42
+ types = frozenset(t for t in advertised or ["convert"] if t in JOB_TYPES)
43
+ return "external", types or frozenset({"convert"})
44
+ finally:
45
+ client.close()
46
+
47
+
48
+ def open_client(credential, server, *, transport=None):
49
+ """(client, profile) for the full-screen app and the plain CLI."""
50
+ kind, _ = detect_profile(credential, server, transport=transport)
51
+ cls = ExternalClient if kind == "external" else Client
52
+ return cls(credential, server, transport=transport), kind
53
+
54
+
55
+ def submit_external(client, content: bytes, source_vendor: str, target_vendor, job_type: str,
56
+ options: dict, idempotency_key: str):
57
+ """POST /jobs with inline UTF-8 text. ``technology`` follows the vendor code."""
58
+ text = content.decode("utf-8") # callers snapshot UTF-8 text only
59
+ body = {**options, "source_vendor": source_vendor, "source_config": text}
60
+ body.setdefault("technology", device_type_of(source_vendor))
61
+ if target_vendor:
62
+ body["target_vendor"] = target_vendor
63
+ if job_type != "convert":
64
+ body["job_type"] = job_type
65
+ return client.submit_job(body, idempotency_key)
66
+
67
+
68
+ ANALYSIS_RESULTS = {"validate": "validation", "optimize": "optimization"}
69
+
70
+
71
+ def _save_json(output, job_id: str, filename: str, value):
72
+ # Canonical bytes keep a repeat download identical (JSONB key order).
73
+ content = json.dumps(value, indent=2, sort_keys=True, default=str).encode()
74
+ return output.save_artifact(job_id, filename, content, hashlib.sha256(content).hexdigest())
75
+
76
+
77
+ def download_external(client, job_id: str, output) -> list:
78
+ """Translated config, the structured report and the evidence workbook, hash-verified.
79
+
80
+ A validate or optimize job emits no translation: GET /jobs/{id} carries its
81
+ result, saved as ``<job>.validation.json`` / ``<job>.optimization.json``.
82
+ """
83
+ saved = []
84
+ job = client.job(job_id)
85
+ key = ANALYSIS_RESULTS.get(job.get("job_type")) if isinstance(job, dict) else None
86
+ if key:
87
+ result = job.get(key)
88
+ if result is None:
89
+ raise APIError(409, "job_not_completed", detail={"job_id": job_id, "status": job.get("status")})
90
+ saved.append(_save_json(output, job_id, f"{key}.json", {
91
+ "job_id": job_id, "job_type": job["job_type"], "status": job.get("status"),
92
+ "source_vendor": job.get("source_vendor"), key: result}))
93
+ workbook = client.evidence_workbook(job_id)
94
+ if workbook:
95
+ saved.append(output.save_artifact(job_id, "evidence.xlsx", workbook,
96
+ hashlib.sha256(workbook).hexdigest()))
97
+ return saved
98
+ translated = client.translated(job_id)
99
+ if translated:
100
+ saved.append(output.save_artifact(job_id, "translated.txt", translated,
101
+ hashlib.sha256(translated).hexdigest()))
102
+ saved.append(_save_json(output, job_id, "report.json", client.report(job_id)))
103
+ workbook = client.evidence_workbook(job_id)
104
+ if workbook:
105
+ saved.append(output.save_artifact(job_id, "evidence.xlsx", workbook,
106
+ hashlib.sha256(workbook).hexdigest()))
107
+ return saved
@@ -0,0 +1,79 @@
1
+ """NetConverter CLI identity: the setup banner and the app's one-row header."""
2
+ from pathlib import Path
3
+
4
+ from rich.table import Table
5
+ from rich.text import Text
6
+
7
+ from . import __version__
8
+ from .palette import ACCENT, DIM, ERROR, FG, MUTED, SUCCESS, WARNING
9
+
10
+ PRODUCT = 'NetConverter CLI'
11
+ TAGLINE = 'Any-vendor migrations · your AI over MCP · configs never reach the model'
12
+
13
+
14
+ def wordmark(version=True):
15
+ text = Text('◆ ', style=f'bold {ACCENT}')
16
+ text.append('Net', style=f'bold {FG}')
17
+ text.append('Converter', style=f'bold {ACCENT}')
18
+ if version:
19
+ text.append(f' v{__version__}', style=MUTED)
20
+ return text
21
+
22
+
23
+ def folder_label(folder):
24
+ label = str(folder)
25
+ home = str(Path.home())
26
+ return '~' + label[len(home):] if label.startswith(home + '/') else label
27
+
28
+
29
+ def banner(folder, compact=False, width=120):
30
+ """Multi-line identity block for `netconverter setup` and plain output."""
31
+ from .presentation import literal
32
+ text = wordmark()
33
+ text.append(' CLI\n', style=f'bold {FG}')
34
+ text.append(TAGLINE + '\n', style=MUTED)
35
+ folder_text = literal('Folder ' + folder_label(folder), DIM)
36
+ folder_text.truncate(max(16, width - 4), overflow='ellipsis')
37
+ text.append(folder_text)
38
+ return text
39
+
40
+
41
+ def _pill(dot, label, color, value=None):
42
+ text = Text(dot + ' ', style=color)
43
+ text.append(label, style=FG if color != DIM else MUTED)
44
+ if value:
45
+ text.append(' ' + value, style=MUTED)
46
+ return text
47
+
48
+
49
+ def header(width, *, server='checking', profile='', bridge=None, model=None, allowance=''):
50
+ """One row: wordmark left, status pills right; lower-priority pills drop as width shrinks.
51
+
52
+ server: connected | checking | offline. bridge: (client, ago) or None.
53
+ """
54
+ server_pill = {
55
+ 'connected': _pill('●', 'Server', SUCCESS, profile or None),
56
+ 'offline': _pill('●', 'Server', ERROR, 'offline · /allowance'),
57
+ }.get(server, _pill('●', 'Server', WARNING, 'checking'))
58
+ # (priority, pill) in display order; lower priority numbers survive a narrow terminal.
59
+ pills = [(0, server_pill)]
60
+ if bridge:
61
+ pills.append((2, _pill('◆', 'Bridge', ACCENT, f'{bridge[0]} · {bridge[1]}')))
62
+ else:
63
+ pills.append((4, _pill('◇', 'Bridge', DIM, 'idle')))
64
+ pills.append((3, _pill('●', 'Model', SUCCESS, model)) if model else (5, _pill('○', 'Model', DIM, 'none')))
65
+ if allowance:
66
+ pills.append((1, Text(allowance, style=MUTED)))
67
+ left = wordmark()
68
+ budget = max(0, width - left.cell_len - 4)
69
+ kept, used = set(), 0
70
+ for index, (_, pill) in sorted(enumerate(pills), key=lambda item: item[1][0]):
71
+ if used + pill.cell_len + (3 if kept else 0) <= budget:
72
+ kept.add(index)
73
+ used += pill.cell_len + (3 if len(kept) > 1 else 0)
74
+ right = Text(' ').join(pill for index, (_, pill) in enumerate(pills) if index in kept)
75
+ grid = Table.grid(expand=True)
76
+ grid.add_column(no_wrap=True)
77
+ grid.add_column(justify='right', no_wrap=True)
78
+ grid.add_row(left, right)
79
+ return grid